Reverse Proxy Breaks Scheme Detection, Uploads and Client IPs
An e-commerce site runs nginx 1.18 as TLS terminator and cache in front of Apache 2.4.54 with mod_php on 127.0.0.1:8080. nginx proxies everything with proxy_pass http://127.0.0.1:8080; and terminates HTTPS with a valid certificate. Since a recent migration three problems appeared:
- Logging in to /wp-admin/ loops: the browser alternates between https://shop.example.com/wp-login.php and http://shop.example.com/wp-login.php until it reports too many redirects.
- Uploading a 40 MB product video returns HTTP 413 from nginx, although PHP runs with upload_max_filesize and post_max_size set to 64M.
- Apache access logs show every request as 127.0.0.1, so abuse reporting and rate limiting are useless.
WordPress's wp-config.php sets WP_HOME and WP_SITEURL to https://shop.example.com, there is no .htaccess rewrite rule, and the application code must not be modified. The change window is 30 minutes and you cannot take the site down.
1. Explain the mechanism of the login loop and give the minimal change on each tier that stops it without touching application code.
Read the worked answer
WordPress decides whether the request is secure from $_SERVER['HTTPS'] (its is_ssl() helper). Behind the proxy Apache only ever sees a plain HTTP connection, so is_ssl() is false. WordPress then treats the current URL as http and, because WP_HOME/WP_SITEURL are https, issues a redirect to the canonical https URL. The browser reconnects over https, nginx proxies again over http, WordPress redirects again - an infinite ping-pong that ends only when the browser's redirect cap trips.
Fix on nginx: pass the original scheme in the same block as proxy_pass:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Fix on Apache (mod_php): make Apache translate that header into the server variable PHP reads, in the vhost:
SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on
If PHP were served through FPM you would instead use fastcgi_param HTTPS on; guarded by the same condition. Restrict the header to the proxy - SetEnvIf Remote_Addr "^127\.0\.0\.1$" https_proxy combined with the scheme check - otherwise any client can claim https and influence generated links and cookie flags.
Verify with curl -I https://shop.example.com/wp-admin/: one 302 to the same scheme, not a chain, and grep -i 'location:' shows https while the login page loads. Also confirm cookies are issued with the Secure flag, since WordPress now believes it is on TLS.
2. Explain why the 40 MB upload gets a 413 even though PHP allows 64 MB, and list the parameter changes needed across both tiers.
Read the worked answer
The rejection happens in nginx before the request is ever proxied, which is why nothing appears in the PHP error log and the response body is nginx's own HTML page. client_max_body_size defaults to 1m; the Content-Length of 40 MB exceeds it and nginx answers 413 immediately. PHP's 64M limits are never consulted, so tuning them further is pointless.
Changes:
- nginx, in the
serverblock for the site:client_max_body_size 64m;. Keep it at or slightly below the PHP limit so nginx rejects obvious abuse cheaply. Checkclient_body_temp_pathhas at least 40 MB free, because nginx buffers the body to disk before forwarding;proxy_request_buffering off;is only worth adding if slow uploads are timing out upstream. - Apache:
LimitRequestBodydefaults to 0 (unlimited), so no change - unless mod_security is loaded, in which case checkSecRequestBodyLimitandSecRequestBodyNoFilesLimit, which are frequent hidden causes. - PHP: ensure
post_max_sizeexceedsupload_max_filesize(64M and 64M means a 64 MB file plus multipart overhead fails; set 65M), and checkmax_execution_timeandmemory_limitfor the processing step.
Apply with nginx -t && systemctl reload nginx (reload, not restart, to respect the no-downtime window), then test end to end with curl -F "[email protected]" https://shop.example.com/upload and confirm the response is 200 and the PHP log shows no error. If a CDN sits in front, its own body limit must be raised too.
3. Restore accurate client addresses in Apache logs and explain the spoofing risk of the header you rely on.
Read the worked answer
Apache must be told to trust the forwarded address and to rewrite the connection's notion of the remote host; otherwise %h/%a will always be the proxy.
Enable mod_remoteip and configure it:
RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 127.0.0.1
LogFormat "%a %l %u %t \"%r\" %>s %b" combined
RemoteIPInternalProxy is the trust list - mod_remoteip only rewrites the client address when the direct peer is listed, so a request arriving from anywhere else cannot forge it. Set the header in nginx with proxy_set_header X-Forwarded-For $remote_addr; if nginx is the only edge; use $proxy_add_x_forwarded_for only when a trusted CDN sits in front of nginx, because that variant appends to a client-supplied value and the leftmost entry is attacker-controlled. In the CDN case, also enable nginx's realip module with set_real_ip_from for the CDN ranges instead of trusting a header from arbitrary peers.
The risk is concrete: if Apache trusts X-Forwarded-For from any source, an attacker sets the header and pollutes logs, evades ModSecurity rules keyed on IP and poisons fail2ban, which may then ban an innocent third party. After the change, retest anything that consumes the address: ModSecurity, rate limiting, and fail2ban's log parsing. Verify by requesting from a known address (or adding a debug header) and checking that access_log now shows that address while requests from a non-proxy source still log their real peer.