Skip to content

fix: give nginx and Discourse the visitor's real IP; 2026.9.0-latest:2 → 2026.9.0-latest:3 - #6

Merged
MattDHill merged 1 commit into
masterfrom
fix/real-client-ip
Sep 17, 2026
Merged

MattDHill merged 1 commit into
masterfrom
fix/real-client-ip

Conversation

@MattDHill

@MattDHill MattDHill commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Behind the OS reverse proxy, every visitor reached the image's nginx and Rails as one IP.

The defect

discourse/discourse's nginx sets proxy_set_header X-Forwarded-For $remote_addr, overwriting the client address StartOS puts in that header with its own peer — the bridge gateway, 10.0.3.1. Everything downstream keyed on the client IP therefore saw a single client:

  • nginx's own limit_req zones (flood 12 r/s, bot 200 r/min) and limit_conn connperip 20, which the image ships enabled. Measured on a dev box with real forum data: 66 of 80 parallel page requests answered 429. Under any real concurrency the forum rate-limits itself as one user.
  • Discourse's request tracker, which exempts private addresses from its per-IP limits — so those were silently off.
  • Every IP Discourse records: user_auth_tokens.client_ip, login history, screened IPs, the admin's "users by IP" views.

The fix

prepare-app drops conf.d/outlets/before-server/10-startos-real-ip.conf — an outlet the image's discourse.conf already includes in http context — trusting the two bridge gateways the OS proxy connects from (10.0.3.1, fd00:3::1, per lxc-net's config in the OS build) with real_ip_header X-Forwarded-For. The OS proxy sets that header to the client and strips any client-supplied copy, so $remote_addr is the visitor again before the rate limiter runs and before nginx re-emits it to Rails. Only the gateways are trusted, not the bridge subnets: sibling package containers share the bridge and can reach this port directly, so a subnet-wide trust would let one forge the header.

Verified after the change on the same box: nginx's access log and a fresh user_auth_tokens.client_ip both carry the public client address.

Also documented

Restoring a Discourse backup archive onto the package — the path an existing forum takes to move here — verified with a real 400 MB archive from a launcher install: where the archive goes, that allow_restore gates it, that the restorer remaps the backup's base URL to the current primary URL (so the primary URL is a prerequisite, not a follow-up), and that it disables non-staff email. README, instructions.md.

Verified on a dev box

Install of :2 from prod, a launcher archive restored, then this build sideloaded over it as an update: healthy, data intact, outlet present in the container, real IP end to end.

🤖 Generated with Claude Code

@helix-a helix-a left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The real-IP mechanism is sound and the generated nginx configuration validates, but the trusted source range is broader than the reverse proxy and lets sibling service containers forge the identity this change is intended to protect. Please narrow it to the two bridge gateway addresses.

Verified locally with npm ci, npm run check, npm run build, make (x86_64 + aarch64), and nginx -t against the pinned Discourse image.

Comment thread startos/utils.ts Outdated
/** The image's nginx overwrites `X-Forwarded-For` with its peer — the OS proxy on the container bridge — unless the bridge is trusted. */
const REAL_IP_CONF = [
'set_real_ip_from 10.0.3.0/24;',
'set_real_ip_from fd00:3::/64;',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Trust only the OS proxy, not every service container. All package containers sit in these bridge subnets, and <pkg>.startos resolves to a package's container address, so another installed/compromised service can connect directly to Discourse and supply an arbitrary X-Forwarded-For. With the whole /24 and /64 trusted, nginx accepts that spoof as $remote_addr, corrupting exactly the rate limits, login history, and IP moderation records this patch repairs. The reverse proxy originates its backend connections from the bridge gateways, so use set_real_ip_from 10.0.3.1; and set_real_ip_from fd00:3::1; instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — narrowed to 10.0.3.1 and fd00:3::1 in d46d826, README updated to say why the subnets are not trusted.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified at d46d826: the trust is now limited to the two gateway addresses, the README captures the boundary, and the generated config passes nginx -t against the pinned image. This resolves my finding.

…2 → 2026.9.0-latest:3

The image's nginx sets `proxy_set_header X-Forwarded-For $remote_addr`, so
behind the OS reverse proxy every request reaches its `limit_req`/`limit_conn`
zones and Rails as the bridge gateway, 10.0.3.1. Measured on a dev box: 66 of
80 parallel page requests answered 429, and every login was recorded from
10.0.3.1. `prepare-app` now drops an outlet in `conf.d/outlets/before-server/`
that trusts the two bridge gateways the proxy connects from (10.0.3.1,
fd00:3::1) with `real_ip_header X-Forwarded-For`, which the OS proxy sets to
the client and strips from the client. Only the gateways: sibling containers
share the bridge and can reach this port directly, so a subnet-wide trust
would let one forge the header. Verified after the change: nginx logs and
`user_auth_tokens.client_ip` both carry the public client address.

Documents restoring a Discourse backup archive onto the package (verified with
a real 400 MB archive from a launcher install): the base-URL remap makes the
primary URL a prerequisite, and the restorer disables non-staff email.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@helix-a helix-a left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requested trust-boundary fix is correct. Re-verified npm run check, npm run build, both x86_64 and aarch64 package builds, and nginx -t against the pinned image at d46d826.

@MattDHill
MattDHill merged commit 473426f into master Sep 17, 2026
5 of 6 checks passed
@MattDHill
MattDHill deleted the fix/real-client-ip branch September 17, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants