Fix second round of review findings from PR #6029

Security
- Check advanced_config permissions before creating a certificate on
  host update (proxy, redirection and 404 hosts). A refused request
  previously requested a Let's Encrypt certificate first, leaving an
  orphan certificate and using up rate limit.
- Only trust X-NPM-Peer-Addr when it comes with a secret generated at
  container start. The admin nginx sends both headers. Any proxy host or
  stream forwarding to 127.0.0.1:3000 was also a loopback peer, so it
  could forge the header and get a fresh rate-limit bucket on every
  request. Without the secret (dev, or a mounted production.conf), the
  header is ignored.
- Add a login limit per IP across all accounts (30 failed attempts a
  minute) alongside the per-account one. Before this, one address could
  spray passwords over any number of accounts. Behind a shared proxy,
  everyone using that proxy shares this limit.
- Stop trusting X-Real-IP from loopback. It was only there for an older
  version of the rate limiter, and it let a stream host forwarding to a
  local proxy host forge the client IP and get past IP access lists.
  Loopback now behaves as it does on master.

Correctness
- Refuse to delete a certificate that hosts still use (400, with counts
  per host type). Their configs include its files, and a successful
  revoke deleted them anyway, which broke nginx -t. Otherwise the
  certificate is revoked and its files are removed even if the revoke
  fails.
- Let non-admins clear a location's advanced config. Deleting the
  location was already allowed and has the same effect, and a location
  deleted and re-added at the same path arrives with an empty config,
  which was refused.
- Clear proxy host grants (meta.proxy_host_ids) when visibility isn't
  "user" or proxy hosts are hidden, and drop ids of deleted hosts.
  Before, hidden grants came back when proxy hosts were shown again.
  The backend enforces this; the permissions modal also stops sending
  them.
- 404 host and redirection host modals now wait for the current user
  to load, so an admin's advanced config change isn't dropped if they
  submit before it loads.

Tests
- NonAdminHosts.cy.js: refused change doesn't create a certificate,
  location rename and clear rules, grant clearing.
- Certificates.cy.js: deleting a certificate that is in use.
This commit is contained in:
Jamie Curnow committed 2026-11-09 23:39:26 +10:00
1 parent 04c1f5ae76
commit 5b7e34eb67
18 files changed
+423 -62

No files matched your search

+2 -2
View File
@@ -17,8 +17,8 @@ server {
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
# The TCP peer before realip processing; the backend rate limits on this because
# X-Real-IP can be set by any client in the trusted private ranges
# The TCP peer before realip processing. In development there is no peer secret
# (see production.conf.template), so the backend ignores this and limits on the socket
proxy_set_header X-NPM-Peer-Addr $realip_remote_addr;
proxy_pass http://127.0.0.1:3000/;
@@ -18,8 +18,10 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
# The TCP peer before realip processing; the backend rate limits on this because
# X-Real-IP can be set by any client in the trusted private ranges
# X-Real-IP can be set by any client in the trusted private ranges. The secret, generated
# at startup, is how the backend knows this header came from here.
proxy_set_header X-NPM-Peer-Addr $realip_remote_addr;
proxy_set_header X-NPM-Peer-Secret "{{NPM_PEER_SECRET}}";
proxy_pass http://127.0.0.1:3000/;
proxy_read_timeout 15m;
-4
View File
@@ -83,10 +83,6 @@ http {
# Real IP Determination
# Loopback: lets an NPM proxy host forwarding to 127.0.0.1 (e.g. the admin UI on :81)
# pass the real client IP through instead of every request appearing as 127.0.0.1
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
# Local subnets:
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12; # Includes Docker subnet
@@ -16,7 +16,16 @@ if ! [[ "$NPM_ADMIN_PORT" =~ ^[0-9]+$ ]]; then
fi
PRODFILE="/etc/nginx/conf.d/production.conf"
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g"
# A new secret on every start. The admin nginx sends it with X-NPM-Peer-Addr, and the backend
# only trusts that header when the secret matches, so a proxy host or stream forwarding to the
# backend port can't pass a forged one through.
NPM_PEER_SECRET="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
if [ "${#NPM_PEER_SECRET}" -ne 64 ]; then
log_fatal "Failed to generate the backend peer secret"
fi
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g;s/\{\{NPM_PEER_SECRET\}\}/${NPM_PEER_SECRET}/g"
if is_mounted "$PRODFILE"; then
echo "WARNING: skipping ${PRODFILE} — mounted file" >&2
@@ -24,6 +33,8 @@ elif [ -f "$PRODFILE.template" ]; then
if sed -E "$SED_REGEX" "$PRODFILE.template" > "$PRODFILE" && [ -s "$PRODFILE" ]; then
# success
log_info "Generated ${PRODFILE} from template"
# Hand the secret to the backend; with-contenv loads this directory into its environment
printf '%s' "$NPM_PEER_SECRET" > /var/run/s6/container_environment/NPM_PEER_SECRET
else
log_fatal "Failed to generate ${PRODFILE} from template"
fi