The Forgotten Port Forward: When “Local Only” Was Not
A routine Node.js upgrade turned up an old port forward that exposed a development server with a known PHP flaw to the internet: how it was found by testing from outside, closed and checked for damage, and what it taught.
The setup
A small personal website runs on a VPS. At home, a Raspberry Pi runs a development copy in a Docker container, including unfinished pages that are not meant to be public. In front of that container sits a reverse proxy, also in Docker, which handled the domain names and certificates back when the website itself ran at home. For that, the router had a port forward for ports 80 and 443 to the Pi.
When the website moved to the VPS, the domain names moved along. The proxy and the port forward stayed behind. Nobody looks at something that keeps working without complaint.
What happened
In the php2shell incident two weeks earlier, I patched the VPS within minutes of the fix. The development copy had to wait, because its official Docker image did not have the fix yet. I noted it as a follow-up: "local network only". Nobody checked that sentence, including me.
During a maintenance session I set out to upgrade Node.js on the Pi. To judge how urgent that was, I had to know whether anything on the Pi could be reached from the internet. So instead of reading the configuration, I connected from the VPS to the Pi's public address.
$ for p in 22 80 443; do
timeout 5 bash -c "exec 3<>/dev/tcp/203.0.113.10/$p" 2>/dev/null \
&& echo "port $p open" || echo "port $p closed"
done
port 22 closed port 80 open port 443 open
The next question was what answered there. Because the domain name
points to the VPS, curl --resolve sends the request to
the Pi's address anyway:
$ curl -s -o /dev/null -w '%{http_code}\n' \
--resolve example.org:80:203.0.113.10 \
http://example.org/drafts/unfinished-page.html
200
The development copy, unfinished pages included, was on the internet. So was the PHP version that I had left for later.
Timeline
Times on 5 November 2026 are Dutch time (CET, UTC+1) and approximate.
| Time | Event |
|---|---|
| Earlier in 2026 | The website moves to the VPS. The proxy and the port forward stay in place. |
| 21 October | php2shell: the VPS is patched. The update of the development copy is postponed as "local network only". |
| Afternoon, 5 November | Starting a Node.js upgrade, I test from the VPS which ports on the Pi answer. Ports 80 and 443 do. |
| Minutes later | Requests from outside reach the development copy, with PHP 8.2.34, without the fix. The Node.js upgrade waits. |
| About 15:20 | The proxy is stopped; from outside the connection fails. |
| About 15:25 | The container is rebuilt with the fixed image, PHP 8.2.35. |
| Shortly after | The port forward is removed from the router. |
| Rest of the afternoon | Node.js and Docker are upgraded, API keys are revoked or replaced, write permissions are narrowed. |
Why it was serious
- A known flaw on a public address. For fifteen days after the fix came out, a PHP version with a published remote code execution flaw could be reached from the internet.
-
Someone was knocking. The proxy's logs showed
what any public address gets: automated scanners. Some requests
tried to pass PHP settings through the URL, such as
allow_url_includewithauto_prepend_file=php://input. The ones I looked at got an ordinary page or a 404, but they show this was not a theoretical audience. - Unfinished work was public. Draft pages and projects in progress were one request away. They contain nothing secret, but they were not meant to be seen.
-
What the container could reach. A web server that
blocks
.envfiles over HTTP (it did) offers no protection against code running inside it. Such code could read whatever the container's user could read, including some API keys. And the files of this copy are the source of what gets deployed to the real website.
The cause
There was no single mistake; three reasonable things added up.
- A move leaves things behind. Moving the website took everything that visibly mattered. A port forward in a router has no owner and sends no reminders.
- An assumption became a fact. "Local network only" sounded like knowledge. It was a memory of how things used to be, written down in a follow-up, and after that nobody questioned it.
- The firewall looked like it was doing the job. The host firewall on the Pi has careful rules, but Docker's published ports bypass ufw. Its rules said one thing, and the traffic did another. As far as I can tell, the router was the only real boundary, so the forward in the router decided everything.
Closing it
The order mattered. First close the door, then repair what is behind it, then remove the reason the door existed.
- Stop the proxy. One command, immediately effective and easy to undo. All domain names already pointed to the VPS, so the real website did not notice.
- Rebuild the container. By now the official image had the fix.
- Remove the port forward. This is the real fix. With only the proxy stopped, starting it again would have reopened everything.
Looking for damage
A closed door says nothing about what came in while it was open. I looked at what an intruder would most likely have touched:
-
docker diffon the container: no changed files apart from its mount points. - The processes in the container: only Apache, as its own user.
- PHP files changed since the incident, and files owned by the web server's user: nothing unexpected.
- Git: no tracked file in the website's folder differed from the last commit, and the deploy's dry runs listed only files I expected. So nothing had been carried along to the real website.
All of that is reassuring, and none of it is proof. Logs are kept for a limited time, and a careful intruder leaves few traces. That is why the next step did not depend on the answer.
Treating the keys as seen
I listed every file with secrets the container could read, by testing as its user and showing only the names of the keys, never the values. That list was shorter than I feared, and more interesting:
- Two API keys for AI services, in a file from months ago that no code used anymore. Those could have cost real money. They had not been used for a long time; I revoked them and removed the file. The safest secret is one that no longer exists.
- A key for a data service used by a live project. Replaced on both machines. That service can only roll a key, so the old one dies at once: a short interruption is part of the plan.
- A key for a project on hold. Revoked; it can get a new one when work resumes.
- Database passwords, which the container also had in its environment. That database is only reachable from inside.
SSH keys, the GitHub token and credentials outside the mounted folders were not visible to the container, which is what you hope to find. More on this in Secrets in automation.
The check that checked nothing
A small script tests whether an old key is really dead: it takes the old value from the newest backup and expects a 401. It printed exactly that. But there were two backups. The first attempt at replacing the key had stored a badly pasted value, twice the normal length, and the second attempt had backed that up. So the script had tested the pasted value, and a 401 on nonsense proves nothing.
Tested against the real old key, the result was also a 401, and the new key gave a 200. It ended well, but the green check had not earned it. Noticing the odd file size was what gave it away.
Verification
- From the VPS, all the ports I tried on the Pi, on IPv4 and IPv6, are closed; a request to port 80 or 443 now times out at the router.
- The real website answered on every page I checked, and so did the development copy on the local network.
- The container reports PHP 8.2.35.
What I learned
- Measure from outside. Configuration tells you what you meant. A connection from another machine tells you what is. It took minutes, and I could have done it months ago.
- "Local only" is a claim, so test it. The moment a follow-up says why something can wait, that reason deserves its own check.
- A move is not finished until the old place is empty. Ports, proxies, DNS records and certificates stay behind quietly.
- Delete what you do not use. The unused keys were the most expensive thing in the container's reach, and doing nothing useful.
- Ask what your check really tests. A pass on the wrong input looks exactly like a pass on the right one.
- The next question is often the valuable one. This started as a small upgrade. Asking "how urgent is this?" instead of simply doing it is what found the problem.
Follow-ups
| Action | Why | Status |
|---|---|---|
| Remove the port forward and update the container | The cause and the flaw | Done |
| Revoke or replace the keys the container could read | They may have been seen | Done |
| Narrow what the web server's user may write in the website's folder | Those files are deployed to the real website | Done |
| Correct the php2shell note | It repeated the wrong assumption | Done |
| A weekly maintenance moment, including a port test from outside | So that this is found by routine, not by luck | Started |
Sources
- Docker: packet filtering and firewalls, on published ports and ufw
- docker container diff
- curl: the --resolve option
- On this site: php2shell, Postmortems and Vulnerability management