[PR #178] fix(security): Host-header allowlist + restore dropped security headers #2640

Open
opened 2026-06-07 15:05:36 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/reconurge/flowsint/pull/178
Author: @aaronjmars
Created: 6/5/2026
Status: 🔄 Open

Base: mainHead: security/host-header-allowlist-dns-rebinding


📝 Commits (1)

  • 0a3567a fix(security): Host-header allowlist + restore dropped security headers

📊 Changes

2 files changed (+42 additions, -0 deletions)

View changed files

📝 README.md (+2 -0)
📝 flowsint-app/nginx.conf (+40 -0)

📄 Description

Summary

Adds a Host-header allowlist to flowsint-app/nginx.conf so the frontend rejects requests carrying an unexpected Host header before they reach /api/. Without this check, browser CORS does not protect the API from DNS rebinding: after the attacker's DNS flip the browser treats the rebound request as same-origin and skips the CORS check, leaving POST /api/auth/register and any other unauthenticated route reachable from an attacker page.

Also re-declares the four add_header security headers in the two location blocks (location ~* \.(js|css|...)$ and location = /index.html) that were silently dropping them. nginx's add_header inheritance is replace-all, not merge — a location block that sets any header drops every header inherited from server. The main HTML page was shipping without X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, or Referrer-Policy.

Impact

DNS rebinding (primary). Attacker JS on evil.com makes evil.com resolve briefly to the attacker's IP, then rebinds to a victim's flowsint instance (e.g. 127.0.0.1). Browser sends Host: evil.com to flowsint; the existing server_name _; accepts any Host, and proxy_set_header Host $host; forwards the attacker-controlled value to the FastAPI upstream. The CORS middleware in flowsint-api/app/main.py does not help, because from the browser's perspective the request is same-origin (the JS and the rebound request both use evil.com).

Concrete reachable surface from an attacker page, today, with the default deploy:

  • POST /api/auth/register — open registration. Attacker can plant accounts in the victim's instance. The first registration on a fresh deploy becomes the de-facto admin.
  • POST /api/auth/token — login. Attacker can mount distributed credential-stuffing against the victim's instance from the victim's own browser.
  • GET /health — info leak (low).

JWT bearer tokens are localStorage-scoped to the legitimate origin, so authenticated endpoints don't directly leak — but that's not the boundary the deployment claims. The README's "Everything is stored on your machine" privacy posture assumes the local instance isn't drivable from arbitrary origins; this fix restores that.

Dropped security headers (secondary). With the prior config, hitting / (the SPA) returns /index.html from the location = /index.html block, which sets add_header Cache-Control "no-store, ..." and thereby drops every server-level security header. So the main HTML page goes out with no X-Frame-Options (clickjacking), no Referrer-Policy (cross-site referrer leak), and no X-Content-Type-Options (MIME confusion). Same loss applied to the static-assets location for .js/.css.

Location

  • flowsint-app/nginx.confmap $http_host $is_flowsint_host allowlist + if ($is_flowsint_host = 0) gate at the top of the server block; security headers re-declared in the static-assets and /index.html locations.
  • README.md — one paragraph under "Deploy on a network (team / server)" naming the new operator step for LAN deploys.

Fix

  • New map $http_host $is_flowsint_host block at http level. Default 0; allowlist entries for localhost, 127.0.0.1, [::1] (any port, case-insensitive), matching the documented single-user install path (http://localhost:5173/register). Two commented-out template lines show LAN operators how to add their server hostname/IP.
  • New if ($is_flowsint_host = 0) { return 403; } at the top of the server block, before any header or location processing.
  • In location ~* \.(js|css|...)$ and location = /index.html, re-declare the four add_header security headers that nginx's replace-all inheritance was dropping.
  • README: one paragraph under "Deploy on a network" telling operators to add their hostname/IP to the allowlist alongside the existing .env secret rotation step.

Verification

  • nginx -t -c flowsint-app/nginx.conf -p <prefix>/syntax is ok / test is successful.
  • 26/26 Host-header regression cases pass (allowed: localhost, localhost:5173, 127.0.0.1, LocalHost:5173, [::1], [::1]:5173, etc.; blocked: evil.com, evil.com:5173, localhost.evil.com, 127.0.0.1.attacker.com, 127-0-0-1.attacker.com, 127.0.0.1evil, empty host, 192.168.1.42 (operator must opt-in), AWS-metadata IP, etc.).
  • LAN-deploy regression: 192.168.1.42:5173 is correctly blocked under the default config — the README change tells operators to extend the allowlist (matches existing .env-rotation pattern).
  • Static assets (.js/.css) and /index.html confirmed to keep the four add_header security headers in the patched location blocks.

Detected by

Aeon + semgrep

  • Severity: medium
  • CWE-350 (Reliance on Reverse DNS Resolution for a Security-Critical Action) / CWE-352 (CSRF, the rebinding variant) / CWE-1021 (Improper Restriction of Rendered UI Layers — for the missing X-Frame-Options on /index.html).
  • semgrep rule: generic.nginx.security.request-host-used at flowsint-app/nginx.conf:69 flagged the unvalidated Host propagation; generic.nginx.security.header-redefinition at :84/:90/:102 flagged the silently dropped headers.

Filed by Aeon.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/reconurge/flowsint/pull/178 **Author:** [@aaronjmars](https://github.com/aaronjmars) **Created:** 6/5/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `security/host-header-allowlist-dns-rebinding` --- ### 📝 Commits (1) - [`0a3567a`](https://github.com/reconurge/flowsint/commit/0a3567af965ff890686a1b3aa22c145ef0365f48) fix(security): Host-header allowlist + restore dropped security headers ### 📊 Changes **2 files changed** (+42 additions, -0 deletions) <details> <summary>View changed files</summary> 📝 `README.md` (+2 -0) 📝 `flowsint-app/nginx.conf` (+40 -0) </details> ### 📄 Description ## Summary Adds a Host-header allowlist to `flowsint-app/nginx.conf` so the frontend rejects requests carrying an unexpected `Host` header before they reach `/api/`. Without this check, browser CORS does not protect the API from DNS rebinding: after the attacker's DNS flip the browser treats the rebound request as same-origin and skips the CORS check, leaving `POST /api/auth/register` and any other unauthenticated route reachable from an attacker page. Also re-declares the four `add_header` security headers in the two location blocks (`location ~* \.(js|css|...)$` and `location = /index.html`) that were silently dropping them. nginx's `add_header` inheritance is replace-all, not merge — a location block that sets any header drops every header inherited from `server`. The main HTML page was shipping without `X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`, or `Referrer-Policy`. ## Impact **DNS rebinding (primary).** Attacker JS on `evil.com` makes `evil.com` resolve briefly to the attacker's IP, then rebinds to a victim's flowsint instance (e.g. `127.0.0.1`). Browser sends `Host: evil.com` to flowsint; the existing `server_name _;` accepts any Host, and `proxy_set_header Host $host;` forwards the attacker-controlled value to the FastAPI upstream. The CORS middleware in `flowsint-api/app/main.py` does not help, because from the browser's perspective the request is same-origin (the JS *and* the rebound request both use `evil.com`). Concrete reachable surface from an attacker page, today, with the default deploy: - `POST /api/auth/register` — open registration. Attacker can plant accounts in the victim's instance. The first registration on a fresh deploy becomes the de-facto admin. - `POST /api/auth/token` — login. Attacker can mount distributed credential-stuffing against the victim's instance from the victim's own browser. - `GET /health` — info leak (low). JWT bearer tokens are localStorage-scoped to the legitimate origin, so authenticated endpoints don't directly leak — but that's not the boundary the deployment claims. The README's "Everything is stored on your machine" privacy posture assumes the local instance isn't drivable from arbitrary origins; this fix restores that. **Dropped security headers (secondary).** With the prior config, hitting `/` (the SPA) returns `/index.html` from the `location = /index.html` block, which sets `add_header Cache-Control "no-store, ..."` and thereby drops every server-level security header. So the main HTML page goes out with no `X-Frame-Options` (clickjacking), no `Referrer-Policy` (cross-site referrer leak), and no `X-Content-Type-Options` (MIME confusion). Same loss applied to the static-assets location for `.js`/`.css`. ## Location - `flowsint-app/nginx.conf` — `map $http_host $is_flowsint_host` allowlist + `if ($is_flowsint_host = 0)` gate at the top of the `server` block; security headers re-declared in the static-assets and `/index.html` locations. - `README.md` — one paragraph under "Deploy on a network (team / server)" naming the new operator step for LAN deploys. ## Fix - New `map $http_host $is_flowsint_host` block at `http` level. Default `0`; allowlist entries for `localhost`, `127.0.0.1`, `[::1]` (any port, case-insensitive), matching the documented single-user install path (`http://localhost:5173/register`). Two commented-out template lines show LAN operators how to add their server hostname/IP. - New `if ($is_flowsint_host = 0) { return 403; }` at the top of the `server` block, before any header or location processing. - In `location ~* \.(js|css|...)$` and `location = /index.html`, re-declare the four `add_header` security headers that nginx's replace-all inheritance was dropping. - README: one paragraph under "Deploy on a network" telling operators to add their hostname/IP to the allowlist alongside the existing `.env` secret rotation step. ## Verification - `nginx -t -c flowsint-app/nginx.conf -p <prefix>/` → `syntax is ok` / `test is successful`. - 26/26 Host-header regression cases pass (allowed: `localhost`, `localhost:5173`, `127.0.0.1`, `LocalHost:5173`, `[::1]`, `[::1]:5173`, etc.; blocked: `evil.com`, `evil.com:5173`, `localhost.evil.com`, `127.0.0.1.attacker.com`, `127-0-0-1.attacker.com`, `127.0.0.1evil`, empty host, `192.168.1.42` (operator must opt-in), AWS-metadata IP, etc.). - LAN-deploy regression: `192.168.1.42:5173` is correctly blocked under the default config — the README change tells operators to extend the allowlist (matches existing `.env`-rotation pattern). - Static assets (`.js`/`.css`) and `/index.html` confirmed to keep the four `add_header` security headers in the patched location blocks. ## Detected by Aeon + semgrep - Severity: medium - CWE-350 (Reliance on Reverse DNS Resolution for a Security-Critical Action) / CWE-352 (CSRF, the rebinding variant) / CWE-1021 (Improper Restriction of Rendered UI Layers — for the missing X-Frame-Options on `/index.html`). - semgrep rule: `generic.nginx.security.request-host-used` at `flowsint-app/nginx.conf:69` flagged the unvalidated `Host` propagation; `generic.nginx.security.header-redefinition` at `:84`/`:90`/`:102` flagged the silently dropped headers. --- Filed by [Aeon](https://github.com/aaronjmars/aeon-aaron). --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-06-07 15:05:36 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/flowsint#2640