- Status Closed
-
Assigned To
cbay - Private
Opened by Bores - 25.08.2026
Last edited by cbay - 26.08.2026
FS#461 - Incomplete fix for FS#401: SSRF guard does not normalise IPv4 literals
Summary
The address guard on the installation script source URI refuses a private destination only when the address is written as a dotted quad. Written as a decimal, octal or hexadecimal integer, the same address passes the guard and the backend opens the connection. The response body of a successful fetch is stored verbatim in the installation script field and read straight back by the submitting user, which is the read-back primitive FS#401 was fixed for. This is a different path from FS#460 : no redirect is involved, the encoded address is the URL submitted in the form.
Vulnerable asset
https://admin.alwaysdata.com/site/application/script/<id>/, field script_upstream_uri.
The fetch is triggered by POST https://admin.alwaysdata.com/site/application/script/<id>/update_script/.
The fetching host identified itself as 2a00:b6e0:1:84:1::1 running python-requests/2.32.5.
Root cause
The guard classifies the host portion of the submitted URL by its textual form. The HTTP client that later opens the connection parses the same host with inet_aton semantics, which accept decimal, octal, hexadecimal and mixed notations for an IPv4 address. The two therefore disagree about which host the URL points to: the guard sees a string it does not recognise as private, and the client resolves it to exactly the private address the guard was meant to block.
The gap is specific to IPv4 literals. I checked the neighbouring cases and they are handled correctly, which is why I am confident this is a parsing gap rather than a missing range: loopback, all three RFC1918 blocks and link-local are refused as dotted quads, and IPv6 is refused in compressed and fully expanded form alike, so the IPv6 path is normalised before the check while the IPv4 path is not. I did not have access to the source, so the mechanism is inferred from behaviour.
Attack path
I used 10.255.255.1:8080 as the destination for the bypass. It is unroutable, so a refusal by the guard returns in well under a second while a real connection attempt shows up as a TCP timeout of roughly 20 seconds. Set script_upstream_uri on a script object you own, then POST to update_script/ and measure.
The guard works for every canonical form:
http://127.0.0.1/ 0.583544s http://192.168.0.1:8080/ 0.632095s http://172.16.0.1:8080/ 0.581063s http://169.254.169.254/ 0.593031s http://[::1]/ 0.634583s http://[fd00:dead:beef:0:0:0:0:1]:8080/ 0.676233s http://10.255.255.1:8080/ 0.586345s
The same address in any other IPv4 notation is not refused, and the request blocks for the full TCP timeout:
decimal http://184549121:8080/ 20.532416s octal dotted http://012.0377.0377.01:8080/ 20.556865s hex dotted http://0xa.0xff.0xff.0x1:8080/ 20.006989s hex flat http://0x0AFFFF01:8080/ 20.100729s
I measured the decimal case six times across separate runs and it blocked every time, between 20.05s and 20.65s, against 0.586s to 0.647s for the dotted quad in the same runs. Note the port: the connection attempts are to 8080, so the reachable set is not limited to 80 and 443.
To rule out the alternative explanation that these strings are treated as hostnames whose DNS lookup simply times out, I pointed the same notations at my own public address 185.31.41.11. Every one was parsed as IPv4 and actually fetched, and your front end's response body was stored in the installation script field:
http://bores.alwaysdata.net/gc.sh 0.620634s stored: echo GUARD-CONTROL-OK http://185.31.41.11/gc.sh 0.585734s stored: Request ID: 6cd9bd2c-b1a04128 http://3105827083/gc.sh 0.590465s stored: Request ID: d6a47ad3-269af3c0 http://0271.037.051.013/gc.sh 0.596060s stored: Request ID: b25f13a3-7f8cfd88 http://0xb9.0x1f.0x29.0xb/gc.sh 0.589441s stored: Request ID: 61ec6cce-f060c454 http://0xB91F290B/gc.sh 0.658276s stored: Request ID: 04bc1339-ad0006fb
The first line is a hostname control on a file I placed on my own site, and it came back with the file contents verbatim, which shows the read-back primitive is intact. The rest are the same file requested through the raw address in each notation; they land on your front end rather than my vhost, so the stored body is your "Site not found" page, each with its own request id.
Only http and https are accepted. The form rejects file://, gopher://, dict:// and ftp:// before any fetch, so there is no protocol smuggling here.
Impact
An authenticated customer can make an alwaysdata backend host open TCP connections to any IPv4 address and port the guard is meant to forbid, and read the full response body back out of the installation script field.
The obvious objection is the one you used to close FS#343 , that customers have unrestricted SSH anyway and the job runner executes in the same context and permissions as the SSH server. I tested that objection rather than assuming it does not apply, by running the same destinations from my own shell account on ssh2 and through the fetcher:
destination from my shell (ssh2) through the fetcher 10.0.0.1:8080 Errno 113 No route to host, 0.00s 20.384915s 192.168.0.1:8080 timed out, 8.00s 20.082109s 172.16.0.1:8080 timed out, 8.01s 20.093270s
The shell side of that table is reproducible in one command on any hosting account:
python3 -c "import socket,time
s=socket.socket(); s.settimeout(8); t=time.time()
try: s.connect(('10.0.0.1',8080)); print('open')
except Exception as e: print(type(e).__name__, e, round(time.time()-t,2))"
The first row is the important one. My shell has no route to 10.0.0.0/8 at all, so the kernel rejects it immediately, while the fetcher accepts the same destination for routing and sits there until the connection times out. Whatever the fetcher is attached to, it is not the network position my SSH session has. So this is not the situation you assessed in FS#343 : the bypass grants reach that the customer does not already have.
The states are also distinguishable from the outside, which makes this usable for mapping rather than only for blind requests. A destination that answers HTTP returns its body into a field I read, measured against my own host. A refused port returns in about 0.6 seconds, measured against port 9 on my own address, which my shell confirms is refused. A filtered or unrouted destination takes about 20 seconds.
On what is actually behind the guard, I stopped deliberately. Following your SSRF guidance I made exactly one request to an internal destination, http://2130706433/, the fetcher's own loopback on port 80. It was refused in 0.605334 seconds and nothing was returned or stored. I did not try another port or address, so I cannot tell you what is reachable there. I am happy to demonstrate that on request or in a development environment.
Suggested fix
Resolve the host to an address before the policy decision and apply the policy to the resolved address rather than to the submitted string. Parsing the host with ipaddress.ip_address() after normalising through socket.getaddrinfo() covers the decimal, octal, hexadecimal and mixed notations in one step, because the check then runs on the same value the HTTP client will connect to. Rejecting host forms that are not a plain dotted quad, a bracketed IPv6 literal, or a DNS name would close the gap as well, and is easier to test.
Either way it is worth adding regression cases for the four notations above. The IPv6 path already behaves correctly, so the fix only needs to bring IPv4 up to the same standard.
Testing notes
All testing was done against a script object on my own account, which has been deleted. Manual requests only, no automated scanner, a handful of requests spaced by seconds. The destination used for the bypass proof is unroutable RFC1918 space and the destination used for the parsing proof is my own public address. The single internal request is the one described under Impact.
Loading...
Available keyboard shortcuts
- Alt + ⇧ Shift + l Login Dialog / Logout
- Alt + ⇧ Shift + a Add new task
- Alt + ⇧ Shift + m My searches
- Alt + ⇧ Shift + t focus taskid search
Tasklist
- o open selected task
- j move cursor down
- k move cursor up
Task Details
- n Next task
- p Previous task
- Alt + ⇧ Shift + e ↵ Enter Edit this task
- Alt + ⇧ Shift + w watch task
- Alt + ⇧ Shift + y Close Task
Task Editing
- Alt + ⇧ Shift + s save task
poc.sh
Hello,
Can you confirm that the bug is fixed?
Kind regards,
Cyril
Hello,
Fixed. The four notations are refused sub-second now, and so are seven more that were not in the report, so it is real normalisation rather than a blacklist of the strings I sent. My own public address in those same notations is still parsed and fetched, so the feature is intact. The redirect path is covered too: behind a redirector whose Location header keeps the encoded form the private notations are refused, while a redirect to 3105827083 still follows the hop and stores the response.
It cost some range coverage, though. The seven canonical addresses
FS#461records as refused, against the figures already in the task:Same reading as the original report: sub-second is a refusal, the full timeout is a real connection to something that does not answer. Not load, since the split held over three rounds interleaved with 10.255.255.1 and again in a second pass from a new script object, and not those addresses alone: 192.168.1.1 and 172.31.0.1 time out too, as do the encoded forms of the 192.168 and 172.16 rows.
The ULA row needs no timing argument. An HTTP server on my own account's ULA address, fetched through the field:
So the backend now reaches into fd00::/8, where my own account's address sits, and hands the body back to the customer who asked for it. Separate from
FS#462, which is about the global IPv6 your own hosts use.I did not look at what is behind any of those ranges, per your SSRF guidance. Listener stopped, test objects deleted.
Would you rather have the regression as its own task?
Kind regards,
Bores
No, the regression is normal and expected. From now on, the HTTP request is made as the user account from the SSH server, so reachable URLs are exactly the same as what the user could do by running the curl command.
You can open a support ticket to claim your bounty.