ci / bun-nix (pull_request) Successful in 35s
ci / docs-drift (pull_request) Successful in 35s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m42s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 6m46s
ci / rust (pull_request) Failing after 4m25s
android / android (pull_request) Canceled after 9m18s
nix / flake (pull_request) Canceled after 6m51s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 1s
Security review 2026-08-25, 58 confirmed findings across host, console, clients and supply chain. Nearly every serious one is a documented boundary whose code stopped enforcing what its comment promised — so where the two disagreed, the comment won and the code was made to match, and where it could not be, the comment was corrected instead. Critical — a console session cookie alone reached code execution: every pairing route rode the generic catch-all with the operator's admin bearer attached. Arming, approving and PIN submission now sit behind the console password like the other trust-root routes, and the armed PIN is returned once in that gated response instead of riding a 1 s status poll. High — the plugin lane no longer reads the unredacted log ring (which carried the webhook credentials the /hooks carve-out exists to withhold); hook lines log an origin and a short id, never a URL or a command line; a plugin-reported pid is held to procscan's start-time floor before the SYSTEM host will signal it; ClipOffer is gated on the live grant mask, so a revoked guest loses the host clipboard in both directions; ENet refuses connects with no live launch instead of letting LAN peers squat all four slots; Windows secrets are born with their DACL applied rather than world-readable; the sysext feed binds FEED and a monotonic SERIAL inside the signed bytes; privileged_field allowlists the host-resolved launch kinds so a new kind is privileged by default; five parser panics reachable from one malformed NALU are range-checked; release-signing jobs pin bun, sccache and actions by checksum/SHA; h2 -> 0.4.19 (RUSTSEC-2026-0258). Deep links only auto-dial by stable record id now — a display name or an address gets a confirmation on every client. The Apple identity key moves to ThisDeviceOnly so it stops riding encrypted backups. pf-vdisplay stops routing session identity through the process environment: the injector backend threads through a typed slot, so per-batch getenv no longer races a per-session setenv. The four remaining writes have no in-repo readers and are documented as such; the SAFETY proof that claimed ENV_LOCK made them sound is gone. Verified: cargo clippy --workspace --all-targets --locked -D warnings and cargo fmt --all --check clean in the CI image; web builds, tsc --noEmit clean, 22/22 server tests; Swift debug+release + 26/26, Kotlin :kit 7/7. Not fixed, deliberately: the plugin token can still mint command execution (the plugin launch kind exists so a plugin names a command the host runs — per-plugin identity does not change that, and the runner is one process hosting fibers, so there is nowhere to hang a credential); the shared plugin-UI origin; the rollback Authenticode publisher pin (Azure mints a fresh leaf per request, and the signer subject is not in the tree). Each is now described accurately where it lives instead of being claimed closed.
132 lines
5.2 KiB
TypeScript
132 lines
5.2 KiB
TypeScript
// /plugin-ui/<id>/** → a plugin's loopback UI server (plugin-ui-surface §5). By the time we get
|
|
// here the gate (middleware/auth.ts) has confirmed a session — a plugin UI is reachable only by the
|
|
// logged-in operator, on the console's own origin, with no separate password. We look up the
|
|
// plugin's `{port, secret}` server-side, inject the secret as a bearer, strip the browser's cookie,
|
|
// and stream the response through (SSE included). The plugin only ever gets dialed on 127.0.0.1.
|
|
//
|
|
// This route runs in the built Bun/Nitro server. In `vite dev` a small middleware in vite.config.ts
|
|
// handles `/plugin-ui` instead (it intercepts before this route, like the /api dev proxy).
|
|
import {
|
|
defineEventHandler,
|
|
getProxyRequestHeaders,
|
|
getRequestURL,
|
|
readRawBody,
|
|
sendWebResponse,
|
|
setResponseStatus,
|
|
} from "h3";
|
|
import {
|
|
bustCredential,
|
|
fetchUiCredential,
|
|
PLUGIN_ID_RE,
|
|
} from "../../util/pluginProxy";
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const { pathname, search } = getRequestURL(event);
|
|
// /plugin-ui/<id>/<rest…>
|
|
const m = pathname.match(/^\/plugin-ui\/([^/]+)(\/.*)?$/);
|
|
const id = m?.[1];
|
|
if (!id || !PLUGIN_ID_RE.test(id)) {
|
|
setResponseStatus(event, 404);
|
|
return { error: "not a valid plugin-ui path" };
|
|
}
|
|
const rest = m?.[2] ?? "/";
|
|
const prefix = `/plugin-ui/${id}`;
|
|
|
|
// Forwardable request headers (h3 strips hop-by-hop + host); we set our own auth and drop the
|
|
// session cookie so plugin code never sees it.
|
|
const headers = getProxyRequestHeaders(event) as Record<string, string>;
|
|
delete headers.cookie;
|
|
delete headers.authorization;
|
|
headers["x-forwarded-prefix"] = prefix;
|
|
const method = event.method;
|
|
// Only read a body for the methods that can carry one. `readRawBody` asserts a payload method,
|
|
// so calling it for OPTIONS (a plugin UI's CORS preflight, or any client probing Allow) threw
|
|
// 405 out of the CONSOLE before the plugin was ever dialed.
|
|
const body = BODY_METHODS.has(method)
|
|
? ((await readRawBody(event, false)) as Uint8Array | undefined)
|
|
: undefined;
|
|
|
|
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
|
|
const attempt = async (bustCache: boolean): Promise<Response | null> => {
|
|
// `null` also covers a port we refuse to dial — the plugin declared it when it registered, so
|
|
// it is not ours to trust (see isDialablePort in util/pluginProxy.ts).
|
|
const cred = await fetchUiCredential(id, { bustCache });
|
|
if (!cred) return null;
|
|
const target = `http://127.0.0.1:${cred.port}${rest}${search}`;
|
|
try {
|
|
return await fetch(target, {
|
|
method,
|
|
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
|
|
body: body as BodyInit | undefined,
|
|
redirect: "manual",
|
|
});
|
|
} catch {
|
|
// The port is dead (plugin crashed/restarted on a new port): drop the stale credential so
|
|
// the next request re-resolves it.
|
|
bustCredential(id);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
let resp = await attempt(false);
|
|
// Stale secret after a plugin restart (S7): the plugin rejects our cached secret — re-fetch once.
|
|
if (resp?.status === 401) {
|
|
const retry = await attempt(true);
|
|
if (retry) resp = retry;
|
|
}
|
|
if (!resp) {
|
|
setResponseStatus(event, 502);
|
|
return { error: `plugin "${id}" is not running` };
|
|
}
|
|
return sendWebResponse(event, sanitize(resp));
|
|
});
|
|
|
|
/** Methods that may carry a request body. Anything else (GET, HEAD, OPTIONS, TRACE) must not be
|
|
* handed to `readRawBody`. */
|
|
const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
|
|
/**
|
|
* Rebuild a plugin's response before it goes out on the console's own origin.
|
|
*
|
|
* An ALLOWLIST, not a denylist. A plugin UI is proxied same-origin by design, so any header it
|
|
* returns is asserted for the console itself — and the first version of this dropped four names it
|
|
* had thought of. `Clear-Site-Data: "*"` from a plugin's error page was not one of them: the
|
|
* browser would honour it for this origin and wipe `pf_session`, signing the operator out of the
|
|
* console because a plugin 500'd. Same shape for a plugin-supplied `Content-Security-Policy`,
|
|
* `X-Frame-Options` or `Access-Control-Allow-Origin` — all of which would speak for us.
|
|
*
|
|
* So: name what a plugin page legitimately needs, and drop the rest. Framing headers
|
|
* (content-encoding/length, transfer-encoding) are deliberately absent — `fetch` already decoded
|
|
* the body, so re-emitting the plugin's originals made compressed pages fail to decode; ours are
|
|
* recomputed.
|
|
*/
|
|
const PLUGIN_HEADER_ALLOWLIST = new Set([
|
|
"content-type",
|
|
"cache-control",
|
|
"etag",
|
|
"last-modified",
|
|
"expires",
|
|
"vary",
|
|
"content-language",
|
|
"content-disposition",
|
|
"accept-ranges",
|
|
"content-range",
|
|
"location", // its own redirects, within its own prefix
|
|
"link", // preload hints for its own assets
|
|
"x-forwarded-prefix",
|
|
]);
|
|
|
|
function sanitize(resp: Response): Response {
|
|
const headers = new Headers();
|
|
for (const [k, v] of resp.headers) {
|
|
if (PLUGIN_HEADER_ALLOWLIST.has(k.toLowerCase())) headers.set(k, v);
|
|
}
|
|
// 204/304 must not carry a body — passing one through throws in the Response constructor.
|
|
const bodyless = resp.status === 204 || resp.status === 304;
|
|
return new Response(bodyless ? null : resp.body, {
|
|
status: resp.status,
|
|
statusText: resp.statusText,
|
|
headers,
|
|
});
|
|
}
|