How Unvalidated URL Input Handling Happens in SvelteKit with Tauri and How to Fix It
Introduction
The src/routes/+page.svelte file handles user-provided file paths and URLs from both query parameters and drag-drop events—a common pattern in desktop applications built with Tauri. However, a flaw at line 113 created a critical security risk: the route accepted http:// URLs, https:// URLs, and arbitrary local Unix file paths, then fetched their contents without any domain allowlisting or scheme restriction.
The vulnerable conditional statement:
if (inputPath.startsWith('http://') || inputPath.startsWith('https://') || (inputPath.startsWith('/') && !isLocalUnixPath)) {
This single condition opened three distinct attack surfaces: plaintext HTTP fetching (vulnerable to man-in-the-middle attacks), arbitrary server-side requests via the Tauri backend, and direct local file reads through the browser's fetch() API. For a desktop application that processes URLs from external sources, this is a textbook Server-Side Request Forgery (SSRF) combined with local file disclosure.
The Vulnerability Explained
Let's examine the vulnerable code block in detail:
if (inputPath.startsWith('http://') || inputPath.startsWith('https://') || (inputPath.startsWith('/') && !isLocalUnixPath)) {
let unityRes = null;
let isUnity = false;
let shouldInvokeBackend = false;
try {
showSpinner = true;
let bytes;
if (inputPath.startsWith('http://') || inputPath.startsWith('https://')) {
const fetched = await invoke('fetch_url_bytes', { url: inputPath });
bytes = new Uint8Array(fetched);
} else {
const fetchRes = await fetch(inputPath);
if (!fetchRes.ok) {
throw new Error(`HTTP error status: ${fetchRes.status}`);
}
const buffer = await fetchRes.arrayBuffer();
bytes = new Uint8Array(buffer);
}
There are three critical problems here:
1. HTTP scheme accepted: The code forwards http:// URLs to the Tauri backend via invoke('fetch_url_bytes', { url: inputPath }). An attacker who controls the inputPath (via query parameter or crafted drag-drop payload) could target internal network services like http://169.254.169.254/latest/meta-data/ (AWS metadata endpoint) or http://localhost:8080/admin.
2. Arbitrary local file paths fetched: The else branch catches paths starting with / that don't match the isLocalUnixPath check (which only covers /Users/, /home/, /tmp/, and /private/). A path like /etc/passwd or /proc/self/environ would bypass the local path check and be fetched directly via fetch(inputPath), potentially exposing sensitive system files.
3. No domain allowlisting: Even for HTTPS URLs, there's no validation of the target domain. The Tauri backend's fetch_url_bytes command will fetch any URL it receives.
Attack Scenario
An attacker crafts a URL to the application with a query parameter containing:
?path=http://169.254.169.254/latest/meta-data/iam/security-credentials/
The application's route handler picks up this value as inputPath, passes the startsWith('http://') check, and invokes the Tauri backend to fetch the AWS instance metadata—returning IAM credentials to the attacker. Alternatively, using a drag-drop event with a path like /etc/shadow (which doesn't match /Users/, /home/, /tmp/, or /private/) triggers the direct fetch() call, potentially exposing password hashes.
The Fix
The fix makes two surgical changes that eliminate both attack vectors:
Before:
if (inputPath.startsWith('http://') || inputPath.startsWith('https://') || (inputPath.startsWith('/') && !isLocalUnixPath)) {
// ...
if (inputPath.startsWith('http://') || inputPath.startsWith('https://')) {
const fetched = await invoke('fetch_url_bytes', { url: inputPath });
bytes = new Uint8Array(fetched);
} else {
const fetchRes = await fetch(inputPath);
if (!fetchRes.ok) {
throw new Error(`HTTP error status: ${fetchRes.status}`);
}
const buffer = await fetchRes.arrayBuffer();
bytes = new Uint8Array(buffer);
}
After:
if (inputPath.startsWith('https://')) {
// ...
const fetched = await invoke('fetch_url_bytes', { url: inputPath });
bytes = new Uint8Array(fetched);
The changes are:
-
Scheme restriction: The outer conditional now only accepts
https://— removing bothhttp://(plaintext, MITM-vulnerable) and the local path condition entirely. -
Removal of local file fetch branch: The entire
elseblock that calledfetch(inputPath)for local file paths is deleted. There is no longer any code path that reads arbitrary local files based on user input. -
Simplified logic: With only HTTPS accepted, the inner conditional is unnecessary. The code now always uses
invoke('fetch_url_bytes', { url: inputPath })for the single remaining case.
This fix preserves all legitimate functionality—users can still load assets from HTTPS URLs—while eliminating the ability to target internal services via HTTP or read local files.
Prevention & Best Practices
-
Principle of least privilege for URL schemes: Only accept the minimum set of URL schemes your application needs. If you only need HTTPS, reject everything else at the input boundary.
-
Domain allowlisting: Even with HTTPS-only, consider maintaining an allowlist of permitted domains. This prevents SSRF against arbitrary external services.
-
Never pass user input to
fetch()for local paths: The browser'sfetch()API can access local resources in certain contexts (especially in Electron/Tauri apps). User-controlled paths should never reach this API without strict validation. -
Defense in depth with Tauri's allowlist: Tauri provides an allowlist configuration for IPC commands. Ensure
fetch_url_byteshas server-side URL validation in the Rust backend as well. -
Input validation at the boundary: Validate URLs immediately when they enter the application (from query params, drag-drop, or any other source) rather than deep in the processing logic.
Key Takeaways
- The
isLocalUnixPathcheck in+page.sveltewas a denylist, not an allowlist—it only blocked four specific path prefixes, leaving/etc/,/proc/,/var/, and countless other sensitive paths accessible. - Accepting
http://URLs in a Tauriinvoke()call creates an SSRF primitive that automated exploit tools can chain with cloud metadata endpoints or internal services. - The
fetch(inputPath)call for local paths was the most dangerous sink—it allowed direct file content exfiltration from the user's filesystem. - Removing an entire code branch is sometimes the best fix—the local file fetch path served no legitimate purpose that couldn't be handled through safer mechanisms.
- Query parameters and drag-drop events are both attacker-controlled input sources in desktop applications and must be treated with the same suspicion as HTTP request bodies in web apps.
How Orbis AppSec Detected This
- Source: User-controlled
inputPathvalue from URL query parameters and drag-drop events insrc/routes/+page.svelte - Sink:
invoke('fetch_url_bytes', { url: inputPath })andfetch(inputPath)at lines 118-126 - Missing control: No URL scheme restriction (http:// accepted), no domain allowlisting, and no validation preventing local file path access via fetch()
- CWE: CWE-918 (Server-Side Request Forgery) and CWE-73 (External Control of File Name or Path)
- Fix: Restricted accepted input to
https://scheme only and removed the local file fetch code path entirely
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability demonstrates how a single overly-permissive conditional in a route handler can create multiple attack vectors simultaneously. By accepting http://, https://, and local file paths without validation, the application exposed itself to SSRF, man-in-the-middle attacks, and local file disclosure. The fix is elegant in its simplicity: restrict to HTTPS only and remove the dangerous local file fetch path. When handling user-controlled URLs in desktop applications built with frameworks like Tauri, always apply the principle of least privilege to URL schemes and validate inputs at the boundary before they reach any fetch or invoke call.