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:
@@ -34,6 +34,17 @@ on:
|
|||||||
#
|
#
|
||||||
# docs/deployment.md carries the design and the rollback procedure.
|
# 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:
|
jobs:
|
||||||
deploy-to-sftp:
|
deploy-to-sftp:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -42,10 +53,10 @@ jobs:
|
|||||||
# Was @v7, which does not exist (latest major is v5): the workflow
|
# Was @v7, which does not exist (latest major is v5): the workflow
|
||||||
# failed on its very first step.
|
# failed on its very first step.
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ concurrency:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
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:
|
env:
|
||||||
PYTHON_VERSION: '3.12'
|
PYTHON_VERSION: '3.12'
|
||||||
|
|
||||||
@@ -23,10 +29,10 @@ jobs:
|
|||||||
name: Security Audit
|
name: Security Audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
@@ -46,10 +52,10 @@ jobs:
|
|||||||
name: Lint with Ruff
|
name: Lint with Ruff
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
@@ -71,10 +77,10 @@ jobs:
|
|||||||
name: Security Scan
|
name: Security Scan
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
@@ -96,10 +102,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [security-audit, lint]
|
needs: [security-audit, lint]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.PYTHON_VERSION }}
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ de la barre latérale (voir `docs/translations.md`).
|
|||||||
```bash
|
```bash
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt
|
.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
|
.venv/Scripts/python run.py # développement, http://127.0.0.1:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
|
||||||
@@ -4,7 +4,22 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Team Tryout Management{% endblock %}</title>
|
<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="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>">
|
<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>
|
</head>
|
||||||
|
|||||||
@@ -114,8 +114,19 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.css" rel="stylesheet">
|
{# The stylesheet that used to sit here — index.global.min.css — does not
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script>
|
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 }}">
|
<script nonce="{{ csp_nonce }}">
|
||||||
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
|
var canScheduleMatches = {% if current_user.can_schedule_matches() %}true{% else %}false{% endif %};
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,52 @@ BACKUP_RETENTION_DAYS=30
|
|||||||
|
|
||||||
**Important**: Never commit `.env` to version control.
|
**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
|
## Step 3: Configure Nginx
|
||||||
|
|
||||||
1. Copy `app/nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`)
|
1. Copy `app/nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`)
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -8,8 +8,33 @@ Usage:
|
|||||||
python wsgi.py
|
python wsgi.py
|
||||||
|
|
||||||
Configuration via environment variables:
|
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)
|
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
|
import multiprocessing
|
||||||
@@ -19,14 +44,65 @@ from app.app import create_app
|
|||||||
|
|
||||||
app = 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__':
|
if __name__ == '__main__':
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
port = int(os.getenv('PORT', 10000))
|
port = int(os.getenv('PORT', 10000))
|
||||||
threads = int(os.getenv('WAITRESS_THREADS', multiprocessing.cpu_count() * 2 + 1))
|
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')
|
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(
|
serve(
|
||||||
app,
|
app,
|
||||||
host=host,
|
host=host,
|
||||||
@@ -35,9 +111,5 @@ if __name__ == '__main__':
|
|||||||
# Graceful shutdown settings
|
# Graceful shutdown settings
|
||||||
channel_timeout=30, # Seconds to wait for in-flight requests
|
channel_timeout=30, # Seconds to wait for in-flight requests
|
||||||
cleanup_interval=30,
|
cleanup_interval=30,
|
||||||
# Waitress Proxy Settings
|
**proxy_settings(),
|
||||||
trusted_proxy='*',
|
|
||||||
trusted_proxy_count=1,
|
|
||||||
trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'},
|
|
||||||
clear_untrusted_proxy_headers=True,
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user