fix(ops): defauts surs a la copie, CDN epingles, actions epinglees

Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune
liste de « ce qui reste » ne reprenait.

OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les
valeurs pour la production », puis posait FLASK_DEBUG=true,
SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug
execute du code soumis par le navigateur : cette ligne transformait un
copier-coller en shell distant. Chaque valeur est desormais sure a la
copie, et le fichier refuse de demarrer tant que les deux secrets
obligatoires ne sont pas remplis plutot que de demarrer grand ouvert.

Renomme en .env.example : l orthographe francaise ne correspondait pas a
l exception !.env.example du .gitignore, donc le fichier n etait suivi que
par accident de l ordre des regles. Les deux points de la decision ouverte
du §8 tombent d un seul git mv.

OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les
defauts sont **inchanges**, deliberement : choisir sans connaitre la
topologie coupe la prod si nginx est ailleurs, ou casse la limitation de
debit pour tout le monde si on cesse de croire X-Forwarded-For alors que
c etait la seule source d adresses. Ce sont maintenant des variables, les
valeurs sures sont dans .env.example pour un nouveau deploiement, et
docs/deployment.md donne les quatre topologies avec la valeur de chacune.
wsgi.py avertit au demarrage tant que les deux defauts sont en place.

Le commentaire de HOST annoncait « bind to localhost by default » a cote
d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code
faisait l inverse. Il dit maintenant ce qu il fait.

QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte,
depuis des hotes que la CSP autorise nommement. Qui controle ces CDN
controlait ce qui s execute sur chaque page. Empreintes posees, avec ce
que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier,
ca ne prouve pas qu il etait honnete au moment du calcul.

**Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans
le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque
ouverture du calendrier depuis la montee de version. Une feuille de style
en echec est silencieuse dans le navigateur, c est ce qui l a fait durer.

CI-003 — actions epinglees sur un commit, version en commentaire, dans les
deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du
code arbitraire dans le job qui detient la cle SSH de production. Ce job
recoit aussi enfin un bloc permissions.

517 tests.
This commit is contained in:
GGThed
2026-08-11 14:42:23 -04:00
parent 39808dd04e
commit 3882b6035f
10 changed files with 401 additions and 55 deletions
+13 -2
View File
@@ -34,6 +34,17 @@ on:
#
# docs/deployment.md carries the design and the rollback procedure.
# Least privilege (CI-003). This job never writes back to the repository; it
# holds the SSH key to the production node, which makes it the most valuable
# job in either forge to compromise.
permissions:
contents: read
# Actions pinned to a commit, version in the comment. A tag is a moving
# pointer, and moving `v4` here means running arbitrary code in the job that
# holds that key. If the Gitea runner ever fails to resolve a commit ref, it
# fails on the checkout step — loudly, like the `@v7` that did not exist.
jobs:
deploy-to-sftp:
runs-on: ubuntu-latest
@@ -42,10 +53,10 @@ jobs:
# Was @v7, which does not exist (latest major is v5): the workflow
# failed on its very first step.
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
+14 -8
View File
@@ -15,6 +15,12 @@ concurrency:
permissions:
contents: read
# Third-party actions are pinned to a commit, with the version in a comment
# (CI-003). A tag is a moving pointer: whoever can move `v4` runs code in a
# job that holds this repository's token. The comment is what makes the pin
# maintainable — a bare 40-character hash tells a reader nothing about
# whether it is current. Dependabot updates both together.
env:
PYTHON_VERSION: '3.12'
@@ -23,10 +29,10 @@ jobs:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -46,10 +52,10 @@ jobs:
name: Lint with Ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.PYTHON_VERSION }}
@@ -71,10 +77,10 @@ jobs:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -96,10 +102,10 @@ jobs:
runs-on: ubuntu-latest
needs: [security-audit, lint]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
+1 -1
View File
@@ -14,7 +14,7 @@ de la barre latérale (voir `docs/translations.md`).
```bash
python -m venv .venv
.venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt
cp app/.env.exemple .env # puis remplir les valeurs
cp app/.env.example .env # puis remplir SECRET_KEY et DATABASE_URL
.venv/Scripts/python run.py # développement, http://127.0.0.1:5000
```
+115
View File
@@ -0,0 +1,115 @@
# Team Tryouts — environment variables
#
# Copy to .env and fill in. Every value here is a PRODUCTION-SAFE default:
# copying this file and changing nothing gives a locked-down configuration
# that refuses to start until the two required secrets are set, rather than
# a working one that happens to be wide open (OPS-003).
#
# The previous version shipped FLASK_DEBUG=true under a heading that said
# "fill in the values for production". The Werkzeug debugger executes code
# submitted through the browser, so that one line turned a copy-paste into a
# remote shell.
#
# For local development, see the DEVELOPMENT block at the bottom.
# =============================================================================
# Required — the application refuses to start without these
# =============================================================================
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
# Never reuse one between environments: this key signs session cookies, so
# whoever holds it can forge a session for any account.
SECRET_KEY=
# Expected form: postgresql://user:password@host:5432/database
# The psycopg 3 driver is named for you by create_app(); postgresql:// alone
# would send SQLAlchemy looking for psycopg2, which is not installed.
DATABASE_URL=
# =============================================================================
# Security — these defaults assume HTTPS in front. Do not relax them on a
# deployed instance.
# =============================================================================
# Session cookies are only sent over HTTPS.
SESSION_COOKIE_SECURE=true
# Plain HTTP is redirected to HTTPS.
FORCE_HTTPS=true
# The Werkzeug debugger is a remote code execution primitive by design.
# Never true on anything reachable from a network you do not control.
FLASK_DEBUG=false
# Inline <script> without a nonce. Off: every block carries one, and turning
# this on gives up the protection that would have blocked the stored XSS
# (SEC-WEB-001). It exists as an escape hatch, not as a setting to tune.
CSP_ALLOW_INLINE_SCRIPT=false
# Comma-separated origins allowed to call this API cross-site. Empty means
# no CORS policy at all, which is correct: the site renders its own HTML on
# one origin and needs none.
CORS_ALLOWED_ORIGINS=
# =============================================================================
# Networking
# =============================================================================
# Interface Waitress binds. 127.0.0.1 keeps it reachable only through the
# local reverse proxy; 0.0.0.0 exposes it directly and is only correct if
# something else in front is doing the filtering.
HOST=127.0.0.1
PORT=5000
# Whether to believe X-Forwarded-For, and from whom. This decides which IP
# the rate limiter and the audit log record.
#
# (empty) — trust nobody. Correct when nothing proxies the app.
# 127.0.0.1 — trust a reverse proxy on this same machine. The usual case.
# * — trust everyone. Only ever correct if the app cannot be reached
# except through the proxy, at the network level. Otherwise any
# caller can claim any IP and walk around the rate limit.
#
# See docs/deployment.md before changing this (OPS-002).
TRUSTED_PROXY=127.0.0.1
# =============================================================================
# Optional — Discord
# =============================================================================
# Leave ENABLE_DISCORD_BOT=false and the token empty to run without Discord.
ENABLE_DISCORD_BOT=false
DISCORD_BOT_TOKEN=
# OAuth2, for "Connect Discord" on the sign-up page.
# Create an application at https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
# =============================================================================
# Optional — storage
# =============================================================================
# Where uploaded contracts live. Empty means `documents/` beside the
# application. Set it to a path OUTSIDE the deployment directory if you move
# to a release-directory layout, or a deployment will take the documents with
# it (OPS-011, app/storage.py).
DOCUMENTS_ROOT=
# Tables are created at startup when missing. Set to false once Alembic owns
# the schema (DB-002/DB-004): create_all() never ALTERs, so a column added to
# a model is silently absent from an existing database.
AUTO_CREATE_TABLES=true
# =============================================================================
# DEVELOPMENT ONLY — the values to change on a laptop, and nowhere else
# =============================================================================
#
# FLASK_DEBUG=true reloader and interactive debugger
# SESSION_COOKIE_SECURE=false cookies over plain HTTP
# FORCE_HTTPS=false no redirect to HTTPS
# DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
#
# `python run.py` reads DEV_HOST and DEV_PORT rather than HOST and PORT, so a
# development session cannot accidentally inherit a production binding.
-34
View File
@@ -1,34 +0,0 @@
# Team Tryouts Application - Environment Variables
# Copy this file to .env and fill in the values for production
# Security Configuration
# Generate a secure random secret key: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=flask_app_secret_key
# Set to 'true' in production to enable secure cookies (requires HTTPS)
SESSION_COOKIE_SECURE=false
FORCE_HTTPS=false
# Flask Debug Mode - Set to 'true' only in development
FLASK_DEBUG=true
# Discord Bot Token (required for notifications)
# This is the UdeS Esports BOT token, it will send notifications to people that have their
# Dicord_User_ID in the db / remove if you don't want discord notifs.
DISCORD_BOT_TOKEN=my_discord_bot_token
# Discord OAuth2 Configuration (for "Connect Discord" on sign-up page)
# Create an application at https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback
#where to find the db (hosted on render for now)
#Forme attendue : postgresql://utilisateur:motdepasse@hote:5432/base
#Le pilote psycopg 3 est nomme automatiquement par create_app().
DATABASE_URL=URI_vers_db_posgres
#Where the app will be hosted (corresponds to: localhost:5000 in local)
HOST=127.0.0.1
PORT=5000
+16 -1
View File
@@ -4,7 +4,22 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Team Tryout Management{% endblock %}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
{# Subresource integrity (QUA-004). Without it, whoever controls the CDN
controls what runs on every page of this site — and the CSP names these
hosts as allowed, so it would not object.
What SRI does and does not do: it pins this exact file, so the browser
refuses a version that has been altered since. It does not prove the
file was honest when the hash was taken. This hash is the one cdnjs
publishes for the release, not one derived from the copy we downloaded.
integrity requires crossorigin. Changing the version means changing
the hash, or the asset silently stops loading. #}
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous"
referrerpolicy="no-referrer">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏆</text></svg>">
</head>
+13 -2
View File
@@ -114,8 +114,19 @@
{% endblock %}
{% block scripts %}
<link href="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script>
{# The stylesheet that used to sit here — index.global.min.css — does not
exist. FullCalendar 6 bundles its styles into the JS, and that file is not
in the published package: the link had been answering 404 on every calendar
load since the upgrade. A failed stylesheet is silent in the browser, which
is why it survived.
Integrity pins the bundle (QUA-004): this file is executable script from a
third party, on the page that shows every match in the club. See the note
in layouts/base.html for what SRI does and does not promise. #}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"
integrity="sha384-WfE/vOHqht3KDj6FvpwQUf3UxEPUHoGJ3w1yZ8rhpLWnVigt8HjXL2zXqtcfS7mf"
crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script nonce="{{ csp_nonce }}">
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
+46
View File
@@ -62,6 +62,52 @@ BACKUP_RETENTION_DAYS=30
**Important**: Never commit `.env` to version control.
Start from `app/.env.example`, which carries production-safe defaults and
documents every variable. Copying it verbatim gives a configuration that
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
than one that starts and is wide open (OPS-003).
### Binding and proxy trust — read this before going live (OPS-002)
Two variables decide whether the rate limiter, the account lockout and the
audit log mean anything: `HOST` and `TRUSTED_PROXY`. Their built-in defaults
(`0.0.0.0` and `*`) are what this application has always done, kept so that
an existing deployment is not changed under it — **they are not the right
values**, and which values are right depends on your topology.
`TRUSTED_PROXY` decides whose `X-Forwarded-For` Waitress believes, and
therefore which address is recorded and counted. Getting it wrong fails in
one of two directions:
- **too trusting** — anyone who can reach the app without going through nginx
sets their own client address. Rate limiting, lockout and the `ip=` field
in `auth.log` all become suggestions;
- **not trusting enough** — every request appears to come from the proxy. One
shared bucket, so the first person to mistype a password five times locks
the limiter for the whole club.
Find your case:
| Topology | `HOST` | `TRUSTED_PROXY` | Why |
|---|---|---|---|
| **nginx on the same machine as the app** (the common case) | `127.0.0.1` | `127.0.0.1` | Waitress is unreachable except through nginx, and only nginx's forwarded header is believed |
| **App in a Pterodactyl container, nginx elsewhere** | `0.0.0.0` | the proxy's address on the container network, e.g. `10.0.0.5` | The app must accept connections from outside the container, so it cannot bind to loopback. Name the proxy rather than trusting `*` |
| **Same as above, but the port is only reachable from the proxy** (firewall or container network) | `0.0.0.0` | `*` | Acceptable *only* because the network already prevents anyone else connecting. If that is not enforced, this is the first failure above |
| **No proxy at all** | `0.0.0.0` | *(empty)* | Nothing forwards anything; the peer address is the client |
To find out which one you are in, on the node:
```powershell
# Does anything answer on the app's port from outside the machine?
Test-NetConnection <public-ip> -Port 5000
# What address does nginx come from, as the app sees it?
# Set TRUSTED_PROXY= (empty) briefly, make one request, and read auth.log:
# the ip= field is then the real peer — which is the proxy.
```
`wsgi.py` prints a warning at startup while both defaults are in place, so an
unconfigured deployment says so in the Pterodactyl console.
## Step 3: Configure Nginx
1. Copy `app/nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`)
+104
View File
@@ -0,0 +1,104 @@
"""Third-party assets are pinned (QUA-004).
Two CDNs serve this site: cdnjs for Font Awesome, jsDelivr for FullCalendar.
The CSP names both as allowed origins, so neither would be stopped by it —
whoever controls those hosts controls what runs on every page, and on the
calendar page that includes executable script.
`integrity` pins each file: the browser refuses one that has been altered
since the hash was taken. It does not prove the file was honest at that
moment. Worth being clear about, because SRI is often read as more than it
is.
This is a guard, not a test of behaviour. Its job is to fail the day someone
adds a CDN URL without a hash, or bumps a version and leaves the old hash
behind — the second of which fails *silently* in a browser, since a script
that fails its integrity check simply does not run.
"""
import re
import pytest
TEMPLATES = 'app/templates'
#: Hosts we deliberately load from. Anything else on a template needs a
#: decision, not a hash.
KNOWN_CDNS = ('cdnjs.cloudflare.com', 'cdn.jsdelivr.net')
#: Discord avatars. Not a subresource — an <img> src built at render time,
#: which cannot carry an integrity hash and executes nothing.
IMAGE_ONLY_HOSTS = ('cdn.discordapp.com',)
def _templates():
import os
for root, _dirs, files in os.walk(TEMPLATES):
for name in files:
if name.endswith('.html'):
path = os.path.join(root, name)
with open(path, encoding='utf-8') as handle:
yield path, handle.read()
def _tags_loading_from(host, markup):
"""Every <script src> and <link href> pointing at this host."""
pattern = re.compile(
r'<(script|link)\b[^>]*?(?:src|href)\s*=\s*"https://' + re.escape(host) + r'[^"]*"[^>]*>',
re.IGNORECASE | re.DOTALL,
)
return pattern.findall(markup), pattern
@pytest.mark.parametrize('host', KNOWN_CDNS)
def test_every_asset_from_a_cdn_carries_an_integrity_hash(host):
offenders = []
for path, markup in _templates():
_names, pattern = _tags_loading_from(host, markup)
for tag in pattern.finditer(markup):
if 'integrity=' not in tag.group(0):
offenders.append(f'{path}: {tag.group(0)[:120]}')
assert not offenders, 'CDN asset loaded without integrity:\n' + '\n'.join(offenders)
@pytest.mark.parametrize('host', KNOWN_CDNS)
def test_integrity_is_paired_with_crossorigin(host):
"""A browser ignores integrity on a request it did not make in CORS mode,
so the hash without crossorigin is decoration."""
offenders = []
for path, markup in _templates():
_names, pattern = _tags_loading_from(host, markup)
for tag in pattern.finditer(markup):
if 'integrity=' in tag.group(0) and 'crossorigin=' not in tag.group(0):
offenders.append(f'{path}: {tag.group(0)[:120]}')
assert not offenders, 'integrity without crossorigin:\n' + '\n'.join(offenders)
def test_no_unexpected_third_party_host_appears():
"""A new CDN is a decision — a supply-chain dependency and a CSP entry —
not something to notice later."""
allowed = set(KNOWN_CDNS) | set(IMAGE_ONLY_HOSTS)
found = set()
for _path, markup in _templates():
found.update(re.findall(r'https://([a-z0-9.-]+)/', markup, re.IGNORECASE))
unexpected = {host for host in found if host not in allowed}
# Documentation links in comments are fine; only loaded assets matter.
unexpected -= {'discord.com', 'www.w3.org', 'developer.mozilla.org'}
assert not unexpected, f'unexpected third-party hosts in templates: {sorted(unexpected)}'
def test_the_csp_and_the_templates_agree(app):
"""A hash is no use if the CSP blocks the request outright, and an origin
left in the CSP after its last use is a widened policy nobody needs."""
with app.test_request_context():
from app.app import build_csp
policy = build_csp(nonce='x', allow_inline_script=False)
for host in KNOWN_CDNS:
assert host in policy, f'{host} is loaded by a template but absent from the CSP'
+79 -7
View File
@@ -8,8 +8,33 @@ Usage:
python wsgi.py
Configuration via environment variables:
PORT: Port to listen on (default: 5000)
HOST: interface to bind (default: 0.0.0.0 — see the note below)
PORT: Port to listen on (default: 10000)
WAITRESS_THREADS: Number of worker threads (default: CPU*2+1)
TRUSTED_PROXY: whose X-Forwarded-For to believe (default: * — see below)
A note on the three defaults above (OPS-002)
--------------------------------------------
They are what this file has always done, kept deliberately. Two of them are
known to be wrong, and they are *still* not changed here, because the right
value depends on something the repository cannot tell us: is nginx on the
same machine as the app, or is the app in a Pterodactyl container with nginx
elsewhere?
Guessing fails in one of two directions, and neither is recoverable by
reading a log:
- bind to loopback when nginx is on another host, and the site is simply
gone;
- stop trusting X-Forwarded-For when it was the only source of client
addresses, and every request looks like it comes from the proxy — one
shared rate-limit bucket, so the first person to mistype a password five
times locks the limiter for everybody.
So: the values became settings, the safe values are what `.env.example`
carries for a new deployment, and the existing one keeps working untouched
until someone who knows the topology sets them. docs/deployment.md walks
through both cases.
"""
import multiprocessing
@@ -19,14 +44,65 @@ from app.app import create_app
app = create_app()
def proxy_settings():
"""How much of X-Forwarded-For to believe, and from whom (OPS-002).
This decides which address the rate limiter counts against and which one
the audit log records as `ip=`.
The default is `*` — believe anyone. That is the setting the audit called
out for inverting the risk rather than fixing it: before it, rate
limiting was miscalibrated but closed; after it, forgeable on every
request by anyone who can reach the app directly. It stays the default
only because narrowing it blind can take the rate limiter down for every
legitimate user, and it is now one environment variable away instead of
being welded into the file.
Values:
'' trust nobody; remote_addr is the peer. Correct when
nothing proxies the app.
'127.0.0.1' trust a reverse proxy on this same machine. The usual
case, and what .env.example ships.
'*' trust everyone. Only correct when the app cannot be
reached except through the proxy, at the network level.
Returns:
dict: keyword arguments for waitress.serve.
"""
trusted = os.getenv('TRUSTED_PROXY', '*').strip()
if not trusted:
return {'clear_untrusted_proxy_headers': True}
return {
'trusted_proxy': trusted,
'trusted_proxy_count': int(os.getenv('TRUSTED_PROXY_COUNT', 1)),
'trusted_proxy_headers': {'x-forwarded-for', 'x-forwarded-proto'},
'clear_untrusted_proxy_headers': True,
}
if __name__ == '__main__':
from waitress import serve
port = int(os.getenv('PORT', 10000))
threads = int(os.getenv('WAITRESS_THREADS', multiprocessing.cpu_count() * 2 + 1))
host = os.getenv('HOST', '0.0.0.0') # Bind to localhost by default (Nginx reverse proxy)
# The comment that used to sit here said "Bind to localhost by default
# (Nginx reverse proxy)" next to a default of 0.0.0.0 — it described the
# intention and the code did the opposite, which is worse than either:
# the app was reachable directly and nobody reading the file would have
# known. The default is unchanged; the comment now says what it does.
host = os.getenv('HOST', '0.0.0.0') # noqa: S104 — see the module docstring
print(f'Starting Waitress server on {host}:{port} with {threads} threads')
if host == '0.0.0.0' and os.getenv('TRUSTED_PROXY', '*') == '*': # noqa: S104
print(
'WARNING: listening on every interface and trusting X-Forwarded-For from '
'anyone. If this port is reachable without going through nginx, the rate '
'limiter and the audit log can be fed any address a caller likes. '
'Set HOST and TRUSTED_PROXY — see docs/deployment.md (OPS-002).'
)
serve(
app,
host=host,
@@ -35,9 +111,5 @@ if __name__ == '__main__':
# Graceful shutdown settings
channel_timeout=30, # Seconds to wait for in-flight requests
cleanup_interval=30,
# Waitress Proxy Settings
trusted_proxy='*',
trusted_proxy_count=1,
trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'},
clear_untrusted_proxy_headers=True,
**proxy_settings(),
)