Files
enricobuehler 98e68a49a7
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
fix(host,web,clients,ci): the comments were the spec, and the code had drifted
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.
2026-08-26 09:22:36 +02:00

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,
});
}