Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 8 additions & 14 deletions crates/agent-gateway/internal/server/tunnel_rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/json"
"io"
Expand All @@ -18,6 +19,12 @@ import (

const tunnelRewriteBodyMaxBytes = 4 * 1024 * 1024

// Keep the runtime as executable JavaScript so its DOM behavior can be tested
// directly. The very same bytes are used for HTML injection and CSP hashing.
//
//go:embed tunnel_runtime.js
var tunnelRuntimeScript string

type tunnelResponseRewriteKind int

const (
Expand Down Expand Up @@ -220,20 +227,7 @@ func tunnelShimScriptBody(rw tunnelRewrite) string {
if err != nil {
return ""
}
return `(function(config){` +
`if(window.__LIVEAGENT_TUNNEL__&&window.__LIVEAGENT_TUNNEL__.installed)return;` +
`var base=String(config.basePath||"").replace(/\/+$/,"");` +
`window.__LIVEAGENT_TUNNEL__={basePath:base,installed:true};` +
`function rw(input){if(input==null||!base)return input;var raw=input instanceof URL?input.href:String(input);var u;try{u=new URL(raw,location.href)}catch(_){return input}` +
`if(u.host!==location.host||!/^(http:|https:|ws:|wss:)$/i.test(u.protocol))return input;` +
`if(u.pathname===base||u.pathname.indexOf(base+"/")===0)return u.href;` +
`u.pathname=base+(u.pathname==="/"?"/":u.pathname);return u.href}` +
`function rwWs(input){var out=rw(input);try{var u=new URL(String(out),location.href);if(u.protocol==="http:")u.protocol="ws:";if(u.protocol==="https:")u.protocol="wss:";return u.href}catch(_){return out}}` +
`if(window.WebSocket){var NativeWebSocket=window.WebSocket;window.WebSocket=function(url,protocols){return new NativeWebSocket(rwWs(url),protocols)};window.WebSocket.prototype=NativeWebSocket.prototype;["CONNECTING","OPEN","CLOSING","CLOSED"].forEach(function(k){window.WebSocket[k]=NativeWebSocket[k]})}` +
`if(window.EventSource){var NativeEventSource=window.EventSource;window.EventSource=function(url,options){return new NativeEventSource(rw(url),options)};window.EventSource.prototype=NativeEventSource.prototype}` +
`if(window.fetch){var nativeFetch=window.fetch.bind(window);window.fetch=function(input,init){if(input instanceof Request)return nativeFetch(new Request(rw(input.url),input),init);return nativeFetch(rw(input),init)}}` +
`if(window.XMLHttpRequest){var open=window.XMLHttpRequest.prototype.open;window.XMLHttpRequest.prototype.open=function(method,url){arguments[1]=rw(url);return open.apply(this,arguments)}}` +
`})(` + string(config) + `);`
return strings.TrimSpace(tunnelRuntimeScript) + `(` + string(config) + `);`
}

func rewriteTunnelCSSURLToken(token string, rw tunnelRewrite) (string, bool) {
Expand Down
6 changes: 3 additions & 3 deletions crates/agent-gateway/internal/server/tunnel_rewrite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,9 @@ func TestRewriteTunnelHTMLBodyInjectsRuntimeShimBeforeFirstScript(t *testing.T)
t.Fatalf("runtime shim was not injected before app script:\n%s", output)
}
assertContains(t, output, `"basePath":"/t/test-slug"`)
assertContains(t, output, `window.WebSocket=function`)
assertContains(t, output, `window.fetch=function`)
assertContains(t, output, `window.EventSource=function`)
assertContains(t, output, `window.WebSocket = function`)
assertContains(t, output, `window.fetch = function`)
assertContains(t, output, `window.EventSource = function`)
assertContains(t, output, `XMLHttpRequest.prototype.open`)
}

Expand Down
161 changes: 161 additions & 0 deletions crates/agent-gateway/internal/server/tunnel_runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
(function (config) {
if (window.__LIVEAGENT_TUNNEL__ && window.__LIVEAGENT_TUNNEL__.installed) return;
var base = String(config.basePath || "").replace(/\/+$/, "");
window.__LIVEAGENT_TUNNEL__ = { basePath: base, installed: true };

function rw(input) {
if (input == null || !base) return input;
var raw = input instanceof URL ? input.href : String(input);
var u;
try {
u = new URL(raw, location.href);
} catch (_) {
return input;
}
if (u.host !== location.host || !/^(http:|https:|ws:|wss:)$/i.test(u.protocol)) return input;
if (u.pathname === base || u.pathname.indexOf(base + "/") === 0) return u.href;
u.pathname = base + (u.pathname === "/" ? "/" : u.pathname);
return u.href;
}

function rwWs(input) {
var out = rw(input);
try {
var u = new URL(String(out), location.href);
if (u.protocol === "http:") u.protocol = "ws:";
if (u.protocol === "https:") u.protocol = "wss:";
return u.href;
} catch (_) {
return out;
}
}

if (window.WebSocket) {
var NativeWebSocket = window.WebSocket;
window.WebSocket = function (url, protocols) {
return new NativeWebSocket(rwWs(url), protocols);
};
window.WebSocket.prototype = NativeWebSocket.prototype;
["CONNECTING", "OPEN", "CLOSING", "CLOSED"].forEach(function (k) {
window.WebSocket[k] = NativeWebSocket[k];
});
}
if (window.EventSource) {
var NativeEventSource = window.EventSource;
window.EventSource = function (url, options) {
return new NativeEventSource(rw(url), options);
};
window.EventSource.prototype = NativeEventSource.prototype;
}
if (window.fetch) {
var nativeFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
if (input instanceof Request) return nativeFetch(new Request(rw(input.url), input), init);
return nativeFetch(rw(input), init);
};
}
if (window.XMLHttpRequest) {
var open = window.XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function (method, url) {
arguments[1] = rw(url);
return open.apply(this, arguments);
};
}

// Dynamic script/link requests are issued by the browser's resource loader,
// not window.fetch. Rewrite synchronously before invoking native setters;
// a MutationObserver runs too late to prevent a request to the gateway root.
function resourceURL(value) {
// Preserve relative URLs, external origins, non-HTTP schemes and typed
// values (e.g. TrustedScriptURL) instead of bypassing their native checks.
if (typeof value !== "string" || !base) return value;
var raw = value.trim();
if (!/^(\/|https?:\/\/)/i.test(raw)) return value;
var u;
try {
u = new URL(raw, document.baseURI);
} catch (_) {
return value;
}
if (u.origin !== location.origin) return value;
if (u.pathname === base || u.pathname.indexOf(base + "/") === 0) return value;
return rw(u.href);
}

function resourceAttribute(element) {
if (element.namespaceURI !== "http://www.w3.org/1999/xhtml") return "";
if (element.localName === "script") return "src";
if (element.localName === "link") return "href";
return "";
}

function patchURLSetter(constructor, property) {
if (!constructor) return;
var prototype = constructor.prototype;
var descriptor = Object.getOwnPropertyDescriptor(prototype, property);
if (!descriptor || !descriptor.set || !descriptor.configurable) return;
var nativeSet = descriptor.set;
descriptor.set = function (value) {
return nativeSet.call(this, resourceURL(value));
};
Object.defineProperty(prototype, property, descriptor);
}
patchURLSetter(window.HTMLScriptElement, "src");
patchURLSetter(window.HTMLLinkElement, "href");

var nativeSetAttribute = Element.prototype.setAttribute;
var nativeGetAttribute = Element.prototype.getAttribute;
Element.prototype.setAttribute = function (name, value) {
if (typeof name === "string" && name.toLowerCase() === resourceAttribute(this)) {
value = resourceURL(value);
}
return nativeSetAttribute.call(this, name, value);
};
var nativeSetAttributeNS = Element.prototype.setAttributeNS;
Element.prototype.setAttributeNS = function (namespace, name, value) {
if ((namespace == null || namespace === "") && name === resourceAttribute(this)) {
value = resourceURL(value);
}
return nativeSetAttributeNS.call(this, namespace, name, value);
};

// Clones and nodes parsed in a detached fragment can bypass the setters.
// Normalize their resource attributes before insertion starts loading them.
function rewriteElement(element) {
var attribute = resourceAttribute(element);
if (!attribute) return;
var value = nativeGetAttribute.call(element, attribute);
var rewritten = resourceURL(value);
if (rewritten !== value) nativeSetAttribute.call(element, attribute, rewritten);
}
function rewriteTree(node) {
if (!node || typeof node !== "object") return;
if (node.nodeType === 1) rewriteElement(node);
if ((node.nodeType === 1 || node.nodeType === 11) && node.querySelectorAll) {
node.querySelectorAll("script[src],link[href]").forEach(rewriteElement);
}
}
function patchInsertion(prototype, name, allArguments) {
if (!prototype || typeof prototype[name] !== "function") return;
var native = prototype[name];
prototype[name] = function () {
var count = allArguments ? arguments.length : Math.min(arguments.length, 1);
for (var i = 0; i < count; i++) rewriteTree(arguments[i]);
return native.apply(this, arguments);
};
}
["appendChild", "insertBefore", "replaceChild"].forEach(function (name) {
patchInsertion(Node.prototype, name, false);
});
[Element.prototype, Document.prototype, DocumentFragment.prototype].forEach(function (prototype) {
["append", "prepend", "replaceChildren"].forEach(function (name) {
patchInsertion(prototype, name, true);
});
});
[Element.prototype, CharacterData.prototype, DocumentType.prototype].forEach(function (prototype) {
["before", "after", "replaceWith"].forEach(function (name) {
patchInsertion(prototype, name, true);
});
});
patchInsertion(Element.prototype, "insertAdjacentElement", true);
})
171 changes: 171 additions & 0 deletions crates/agent-gateway/test/webui/tunnel-runtime.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { createRequire } from "node:module";
import test from "node:test";

const require = createRequire(new URL("../../web/package.json", import.meta.url));
const { JSDOM, VirtualConsole } = require("jsdom");
const runtime = readFileSync(new URL("../../internal/server/tunnel_runtime.js", import.meta.url), "utf8");
const prefix = "/t/runtime-test";

async function fixture(t) {
const requests = [];
const server = createServer((req, res) => {
requests.push(req.url);
if (!req.url.startsWith(`${prefix}/`)) {
res.writeHead(404).end("wrong root");
} else if (req.url.includes(".js")) {
res.writeHead(200, { "Content-Type": "text/javascript" });
res.end('window.executed = (window.executed || 0) + 1;');
} else if (req.url.includes(".css")) {
res.writeHead(200, { "Content-Type": "text/css" });
res.end("body { border-top-width: 7px; }");
} else {
res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const origin = `http://127.0.0.1:${server.address().port}`;
const dom = new JSDOM("<!doctype html><html><head></head><body></body></html>", {
url: `${origin}${prefix}/`,
resources: "usable",
runScripts: "dangerously",
virtualConsole: new VirtualConsole(),
});
t.after(() => dom.window.close());
const { window } = dom;
const nativeSetAttribute = window.Element.prototype.setAttribute;
window.Request = Request;
window.fetch = fetch;
window.eval(`${runtime}(${JSON.stringify({ basePath: prefix })});`);
return { window, document: window.document, requests, origin, nativeSetAttribute };
}

function loaded(element) {
return new Promise((resolve, reject) => {
element.onload = resolve;
element.onerror = () => reject(new Error(`resource failed: ${element.src || element.href}`));
});
}

test("dynamic scripts load and execute through the tunnel for native URL assignment paths", { timeout: 10000 }, async (t) => {
const { window, document, requests, origin } = await fixture(t);
const variants = [
(script) => { script.src = "/property.js"; },
(script) => { script.setAttribute("src", "/attribute.js?version=2"); },
(script) => { script.setAttribute("SRC", "/uppercase.js"); },
(script) => { script.setAttributeNS(null, "src", "/namespace.js"); },
(script) => { script.src = `${origin}/absolute.js`; },
(script) => { script.src = `//${new URL(origin).host}/protocol-relative.js`; },
];
for (const connected of [false, true]) {
for (const assign of variants) {
const script = document.createElement("script");
const done = loaded(script);
if (connected) document.head.append(script);
assign(script);
if (!connected) document.head.appendChild(script);
await done;
}
}
assert.equal(window.executed, variants.length * 2);
assert.equal(requests.length, variants.length * 2);
assert.ok(requests.every((url) => url.startsWith(`${prefix}/`)), requests.join("\n"));
assert.ok(requests.includes(`${prefix}/attribute.js?version=2`));
});

test("dynamic stylesheets load via properties, attributes and detached HTML fragments", { timeout: 10000 }, async (t) => {
const { document, requests } = await fixture(t);
for (const useAttribute of [false, true]) {
const link = document.createElement("link");
link.rel = "stylesheet";
const done = loaded(link);
document.head.append(link);
if (useAttribute) link.setAttribute("href", "/attribute.css");
else link.href = "/property.css";
await done;
assert.equal(link.sheet.cssRules.length, 1);
}
const template = document.createElement("template");
template.innerHTML = '<link rel="stylesheet" href="/fragment.css">';
const fragment = template.content.cloneNode(true);
const link = fragment.firstChild;
const done = loaded(link);
document.head.appendChild(fragment);
await done;
assert.equal(link.sheet.cssRules.length, 1);
assert.deepEqual(requests, [`${prefix}/property.css`, `${prefix}/attribute.css`, `${prefix}/fragment.css`]);
});

test("insertion APIs rewrite cloned resources before the first request", { timeout: 10000 }, async (t) => {
const { document, requests, nativeSetAttribute } = await fixture(t);
const insertions = [
(node, anchor) => anchor.parentNode.appendChild(node),
(node, anchor) => anchor.parentNode.insertBefore(node, anchor),
(node, anchor) => anchor.parentNode.replaceChild(node, anchor),
(node, anchor) => anchor.parentNode.append(node),
(node, anchor) => anchor.parentNode.prepend(node),
(node, anchor) => anchor.parentNode.replaceChildren(node),
(node, anchor) => anchor.before(node),
(node, anchor) => anchor.after(node),
(node, anchor) => anchor.replaceWith(node),
(node, anchor) => anchor.insertAdjacentElement("afterend", node),
];
for (const [index, insert] of insertions.entries()) {
const container = document.createElement("div");
const anchor = document.createElement("span");
container.append(anchor);
document.body.append(container);
const source = document.createElement("script");
nativeSetAttribute.call(source, "src", `/clone-${index}.js`);
const script = source.cloneNode(true);
const done = loaded(script);
insert(script, anchor);
await done;
}
assert.equal(requests.length, insertions.length);
assert.ok(requests.every((url) => url.startsWith(`${prefix}/clone-`)), requests.join("\n"));
});

test("resource rewriting preserves external, relative, typed and already-prefixed values", { timeout: 10000 }, async (t) => {
const { window, document, origin } = await fixture(t);
for (const value of [
"./relative.js", "../relative.js", "https://cdn.example/lib.js", "//cdn.example/lib.js",
"data:text/javascript,void 0", "blob:https://example/id", `${prefix}/existing.js?v=1#fragment`,
`${origin}${prefix}/existing.js`,
]) {
const script = document.createElement("script");
script.src = value;
assert.equal(script.getAttribute("src"), value);
script.setAttribute("src", value);
assert.equal(script.getAttribute("src"), value);
}
const script = document.createElement("script");
const typedValue = { toString: () => "/typed.js" };
script.src = typedValue;
assert.equal(script.getAttribute("src"), "/typed.js");
script.setAttribute("data-src", "/unrelated.js");
assert.equal(script.getAttribute("data-src"), "/unrelated.js");
const image = document.createElement("img");
image.setAttribute("src", "/unchanged.png");
assert.equal(image.getAttribute("src"), "/unchanged.png");
const setter = Object.getOwnPropertyDescriptor(window.HTMLScriptElement.prototype, "src").set;
window.eval(`${runtime}(${JSON.stringify({ basePath: prefix })});`);
assert.equal(Object.getOwnPropertyDescriptor(window.HTMLScriptElement.prototype, "src").set, setter);
});

test("fetch Request and XHR still reach the target through the prefix", { timeout: 10000 }, async (t) => {
const { window, requests, origin } = await fixture(t);
const response = await window.fetch(new Request(`${origin}/api/health`));
assert.deepEqual(await response.json(), { ok: true });
await new Promise((resolve, reject) => {
const xhr = new window.XMLHttpRequest();
xhr.onload = () => { assert.equal(xhr.status, 200); resolve(); };
xhr.onerror = reject;
xhr.open("GET", "/api/xhr");
xhr.send();
});
assert.deepEqual(requests, [`${prefix}/api/health`, `${prefix}/api/xhr`]);
});
Loading
Loading