Compare commits

...

3 Commits

Author SHA1 Message Date
Cauê Faleiros
3b92813491 fix: recover the customer address behind the host reverse proxy
Some checks failed
Build and deploy / Validate source (push) Successful in 7s
Build and deploy / Integration suite on a real stack (push) Failing after 51s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
The production gateway does not face the internet: nginx-proxy-manager owns
80/443 on the host and proxies to it. So $remote_addr inside the gateway is that
proxy, and overwriting X-Forwarded-For with it discarded the customer address
the proxy had already recorded. Every request would have been attributed to one
internal address, which is exactly the fault 2.1 set out to fix, reintroduced in
production only.

Use real_ip to take the customer address from the proxy's header, trusting only
private networks. A request that reaches the published port directly from the
internet is not trusted, so its header is ignored and $remote_addr stays the
real peer: the anti-spoofing property is kept.

Also downgrade 2.9. TLS is not missing, it is terminated by that proxy. The gap
is that the repository never says so, which would break every session cookie if
the stack moved to a host without one.

Validated with nginx -t against the rendered production configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:07:56 -03:00
Cauê Faleiros
7cab210674 ci: move the integration stack clear of the ports that host already uses
8000 was Portainer's Edge tunnel, not a stray process. The first attempt at a
fix picked 18080/18081, which are the production dtf-cloud stack's own defaults
in docker-compose.yml: it would have passed only while that stack was down and
collided again the moment it came back.

Use 28080/28081/28000/29000/29001, clear of Portainer (8000, 9443), both
production stack definitions (18080/18081 and 8080/8081) and the usual MinIO
ports. The occupants are listed in the workflow so the next person choosing a
port can see what is taken.

Ephemeral ports would remove the guesswork but do not work here: the published
port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP when the
containers start, so it has to be known before they run.

Full suite verified on the new block, including the browser end-to-end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:04:56 -03:00
Cauê Faleiros
24cb52d992 fix: stop the CI stack colliding with ports already used on the runner
The integration job failed with "Bind for 0.0.0.0:8000 failed: port is already
allocated". The runner shares the host's Docker daemon, so every published port
is claimed on the machine itself, where other services already listen. Port 8000
was the first collision; 8080, 8081, 9000 and 9001 were equally exposed.

MinIO's ports were hardcoded, and S3_PUBLIC_ENDPOINT was pinned to
localhost:9000 independently, so moving storage would have broken the presigned
URLs the browser fetches. Both now derive from STORAGE_PORT and move together.

CI runs on 18080/18081/18000/19000/19001. Local defaults are unchanged.

Verified by running the whole stack and the full suite on exactly those ports,
including the browser end-to-end, which downloads through a presigned URL and so
proves the storage endpoint followed the port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:00:17 -03:00
4 changed files with 58 additions and 11 deletions

View File

@@ -31,9 +31,20 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
env: env:
SITE_PORT: "8080" # The runner shares the host's Docker daemon, so every published port is
KANBAN_PORT: "8081" # taken on the machine itself. Known occupants of that host:
API_PORT: "8000" # 8000, 9443 Portainer (the Edge tunnel and its UI)
# 18080/18081 the production dtf-cloud stack (docker-compose.yml defaults)
# 8080/8081 deploy/stack.yaml defaults
# 9000/9001 MinIO defaults elsewhere
# This block avoids all of them. Ephemeral ports are not an option: the
# published port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP
# when the containers start, so it has to be known beforehand.
SITE_PORT: "28080"
KANBAN_PORT: "28081"
API_PORT: "28000"
STORAGE_PORT: "29000"
STORAGE_CONSOLE_PORT: "29001"
COMPOSE: docker compose -f compose.local.yaml COMPOSE: docker compose -f compose.local.yaml
steps: steps:
- name: Checkout - name: Checkout

View File

@@ -240,11 +240,18 @@ records the same name for everyone. The meeting asked for traceability, and the
`kanban/main.py` explicitly designed separation of duties (Mayana classifies, `kanban/main.py` explicitly designed separation of duties (Mayana classifies,
Thales/Alexandre authorise). Needs real per-person accounts with roles. Thales/Alexandre authorise). Needs real per-person accounts with roles.
### `[ ]` 2.9 — No TLS in the stack `(F13)` ### `[~]` 2.9 — TLS is terminated outside the repository `(F13)`
Ports publish plain HTTP on 18080/18081 while `COOKIE_SECURE: "true"` — cookies are Downgraded 2026-09-21. The stack publishes plain HTTP on 18080/18081 while
silently dropped unless something external terminates TLS. Nothing in the repo `COOKIE_SECURE: "true"`, and nothing in the repo provisions certificates — but
provisions certificates; `TAREFAS.md` A2 still lists it as pending. `nginx-proxy-manager` on the host owns 80/443 and terminates TLS in front of it,
so cookies are not being dropped in practice. This is undocumented operational
knowledge rather than a live defect.
What remains: record the proxy in `PORTAINER.md` as part of the deployment
contract, so nobody moves the stack to a host without one and silently breaks
every session cookie. `TAREFAS.md` A2 still lists the certificate as pending;
confirm it is actually issued for the DTF subdomain.
### `[ ]` 2.10 — No email verification, no password recovery `(F14)` ### `[ ]` 2.10 — No email verification, no password recovery `(F14)`
@@ -463,6 +470,17 @@ charges. Fix as part of 1.1.
nginx 1.31.6. With both images at zero CRITICAL, the image scan now **gates on nginx 1.31.6. With both images at zero CRITICAL, the image scan now **gates on
CRITICAL** and reports HIGH. CRITICAL** and reports HIGH.
### 2026-09-21 — from the runner host inventory
- `[x]` Fixed a regression in 2.1: the production gateway sits behind
`nginx-proxy-manager`, so `$remote_addr` there is the proxy, not the customer.
Overwriting `X-Forwarded-For` with it would have recorded the proxy's address for
every request in production — the same bug 2.1 set out to fix. The gateway now
uses `real_ip` to recover the customer's address from the proxy's header, trusting
only private networks, so a request arriving directly at the published port
cannot spoof it. Validated with `nginx -t` against the rendered config.
- `[~]` 2.9 downgraded: TLS is terminated by that proxy, not missing.
### Reporting ### Reporting
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to - `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to

View File

@@ -14,7 +14,7 @@ x-app: &app
APP_ENV: local APP_ENV: local
DATABASE_URL: postgresql://${APP_DB_USER:-dtf_app}:${APP_DB_PASSWORD:-local-app-database-only}@db:5432/${POSTGRES_DB:-dtf_local} DATABASE_URL: postgresql://${APP_DB_USER:-dtf_app}:${APP_DB_PASSWORD:-local-app-database-only}@db:5432/${POSTGRES_DB:-dtf_local}
S3_ENDPOINT: http://storage:9000 S3_ENDPOINT: http://storage:9000
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000} S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork} S3_BUCKET: ${S3_BUCKET:-dtf-local-artwork}
AWS_ACCESS_KEY_ID: ${S3_APP_USER:-dtf_app} AWS_ACCESS_KEY_ID: ${S3_APP_USER:-dtf_app}
AWS_SECRET_ACCESS_KEY: ${S3_APP_PASSWORD:-local-app-storage-only} AWS_SECRET_ACCESS_KEY: ${S3_APP_PASSWORD:-local-app-storage-only}
@@ -74,7 +74,9 @@ services:
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-local-storage-only}
ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"] ports:
- "127.0.0.1:${STORAGE_PORT:-9000}:9000"
- "127.0.0.1:${STORAGE_CONSOLE_PORT:-9001}:9001"
volumes: [storage-data:/data] volumes: [storage-data:/data]
networks: [local, edge] networks: [local, edge]
healthcheck: healthcheck:
@@ -165,9 +167,13 @@ services:
context: . context: .
dockerfile: local/Dockerfile.web dockerfile: local/Dockerfile.web
environment: environment:
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000} S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
ports: ports:
# Published ports are host-wide even bound to loopback, so on a shared
# machine any of them can collide with something unrelated. CI overrides
# every one; see .gitea/workflows/deploy.yml.
- "127.0.0.1:${SITE_PORT:-8080}:80" - "127.0.0.1:${SITE_PORT:-8080}:80"
# Convenience only: the API through its own gateway. No test uses it.
- "127.0.0.1:${API_PORT:-8000}:81" - "127.0.0.1:${API_PORT:-8000}:81"
networks: [local, edge] networks: [local, edge]
depends_on: depends_on:
@@ -184,7 +190,7 @@ services:
dockerfile: local/Dockerfile.web dockerfile: local/Dockerfile.web
environment: environment:
WEB_INDEX: kanban.html WEB_INDEX: kanban.html
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:9000} S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"] ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
networks: [local, edge] networks: [local, edge]
depends_on: depends_on:

View File

@@ -8,6 +8,17 @@ server {
# unavailable or still creating its database schema. # unavailable or still creating its database schema.
resolver 127.0.0.11 ipv6=off valid=10s; resolver 127.0.0.11 ipv6=off valid=10s;
set $api_upstream api:8000; set $api_upstream api:8000;
# This gateway sits behind the host's reverse proxy, so $remote_addr is that
# proxy, not the customer. Recover the real address from the header it sets,
# and only when the connection comes from a private network: a request that
# reaches the published port directly from the internet is not trusted, so
# its X-Forwarded-For is ignored and $remote_addr stays the actual peer.
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index ${WEB_INDEX}; index ${WEB_INDEX};
@@ -29,6 +40,7 @@ server {
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
# Overwrite, never append: $proxy_add_x_forwarded_for keeps any header the # Overwrite, never append: $proxy_add_x_forwarded_for keeps any header the
# client sent, and the leftmost value would then be attacker-controlled. # client sent, and the leftmost value would then be attacker-controlled.
# After real_ip above, $remote_addr is the customer even behind the proxy.
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 5s; proxy_connect_timeout 5s;
proxy_read_timeout 30s; proxy_read_timeout 30s;