Idunworks Issue #01
I can has lingo?

Don't let nginx do your app's homework

The bouncer at the front door is great, as long as nobody comes in through the kitchen.

I was getting a sign-in flow ready to give away as an npm package. The sign-in links you get by email, no password. Before handing code to strangers, I wanted to know what it trusts. The answer, in more places than I liked: nginx.

What this is about. A reverse proxy in front of your app quietly does security work: it sets the visitor's real address, strips forged headers, and makes rate limits per visitor mean something. Why it matters. Code that only works safely behind your proxy is not safe code. Someone runs it without one. Or adds a second hop. Or copies it into a project with a different setup. Then every one of those quiet guarantees is gone, and nothing fails loudly.

So here are the checks, one per thing your proxy might be doing for you without telling you.

1. One place decides who the visitor is

Per-IP rate limits only work if the IP is real. Behind nginx, X-Forwarded-For is overwritten, so it is real. Without nginx it is whatever the visitor typed. A new fake address on every request, and the limit never trips.

The fix is one rule: one component resolves the visitor address, and every hop below it overwrites the headers instead of copying them. In SvelteKit that component is event.getClientAddress(), which adapter-node bases on ADDRESS_HEADER (set it behind a proxy that overwrites the header; leave it unset when the app faces the internet directly). When the app calls a backend on the visitor's behalf:

const headers = new Headers(event.request.headers);
for (const h of ['x-real-ip', 'x-forwarded-for', 'forwarded', 'host', 'connection']) {
	headers.delete(h);
}
const ip = event.getClientAddress();
headers.set('X-Real-IP', ip);
headers.set('X-Forwarded-For', ip);

Copying the browser's headers along "to be helpful" is the bug. Most proxy code does exactly that.

2. A backend believes the header only from a hop it knows

The backend gets the other half of the rule. A forwarded address counts only when the request came from inside your own network. From anyone else, the socket address is the truth.

import ipaddress

def client_ip(peer: str, real_ip: str | None) -> str:
    try:
        internal = ipaddress.ip_address(peer)
        internal = internal.is_private or internal.is_loopback
    except ValueError:
        internal = False
    return (real_ip or peer).strip() if internal else peer

A gotcha for your tests: Python counts the documentation ranges (203.0.113.0/24 and friends) as private. Test a "public" peer with an address that really is public, or your test proves the opposite of what you think.

3. Rate limits live where every process can see them

A counter in process memory looks fine on a laptop. In production it resets on every restart. And during a zero-downtime deploy two copies of the app run at once, each with its own budget. So an attacker gets twice the tries exactly when you're busy watching the deploy.

Put the hits in the database you already have. One row per attempt, one transaction per check, and a lock per key so two requests can't both read "9" and both insert the 10th:

SELECT pg_advisory_xact_lock(hashtext(:key));
DELETE FROM login_attempts WHERE key = :key AND hit_at < now() - interval '60 seconds';
SELECT count(*) FROM login_attempts WHERE key = :key;   -- at the limit: 429
INSERT INTO login_attempts (key, hit_at) VALUES (:key, now());

If the database is down, refuse with a 503. A limiter that fails open is just a suggestion.

4. Check redirect targets the way the browser reads them

?next=/somewhere after sign-in is handy. The usual check is "starts with / and not //". Now try /\t/evil.example, with a tab character after the first slash. The string check passes. The browser strips tabs and newlines while it parses the URL, sees //evil.example, and leaves your site.

Don't out-think the parser. Use it:

const BASE = 'http://same-origin.invalid';
export function safeNext(value: string, fallback = '/'): string {
	if (!value.startsWith('/') || /[\u0000-\u001f\u007f]/.test(value)) return fallback;
	try {
		const url = new URL(value, BASE);
		return url.origin === BASE ? url.pathname + url.search + url.hash : fallback;
	} catch {
		return fallback;
	}
}

No proxy helps you here, by the way. That one is all yours.

5. Keep tokens out of the places that write things down

A sign-in token in ?token= lands in every access log between the mail and your app, plus browser history and any link preview a scanner fetches. Put it in the fragment instead: /signin#token=…. Browsers never send the part after # to a server. The page reads it, strips it from the address bar, and spends it with a POST, so a mail scanner that "clicks" the link doesn't sign anyone in. Add Referrer-Policy: no-referrer and Cache-Control: no-store to that page for good measure.

The test that finds all of this

Run the app with the proxy switched off, and attack it yourself. Send twelve sign-in attempts, each with a new forged X-Forwarded-For. If the twelfth isn't refused, your limit belongs to nginx, not to you.

What we changed, and the other ways to do it

  • The visitor address: one component resolves it, every hop overwrites it, and backends trust it only from internal peers.

  • Rate limits: from process memory to a database table with a lock per key, closed on failure.

  • Redirects: from string checks to the URL parser, with control characters refused.

  • Sign-in tokens: from the query string to the fragment, spent only by POST.

Other ways work too. A fixed list of trusted proxy addresses (what most frameworks call "trusted proxies") is stricter than "any private address". But you have to keep it right every time the network changes. Redis for the limiter is faster and has expiry built in. But it's one more service to run and to secure, and for sign-in traffic a table is plenty. Signed headers between hops (an HMAC over the forwarded address) survive even a compromised neighbour on the network. But every hop needs the key, and that's real work. Pick the cheapest one that still holds when the proxy is gone.

So do you have some special "must haves" and tricks? Hit me in the comments!

Log in to like this article, or create an account .
0 reads

Comments

Log in to post comments. Log in
No comments yet. Be the first to comment.