feat(security): gate public-registry plugin installs + sandbox the runner unit

Supply chain: resolvePackage() used to pass any punktfunk-plugin-* or
foreign-scope name straight to bun add — a typo or a squatted look-alike
on npmjs.org would install operator-privileged code. Only the @punktfunk
scope (pinned to the Gitea registry by the bunfig scope map) resolves by
default now; anything else throws with an explanation unless
--allow-public-registry is passed, and even then installs print a loud
warning. Removal never gates — uninstalling stays safe regardless of
origin.

systemd (user unit): free hardening for well-behaved plugins —
NoNewPrivileges, PrivateTmp, ProtectSystem=strict with ReadWritePaths=%h
(plugin state and download dirs under $HOME keep working), and
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6. Plugins writing
outside $HOME, and distros that restrict unprivileged user namespaces
for user units, are handled via a documented systemctl --user edit
drop-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:20:38 +02:00
parent 7fe07d0fa5
commit 7b7231fdbe
4 changed files with 97 additions and 19 deletions
+17 -2
View File
@@ -10,8 +10,10 @@
# you add scripts or install plugins. Turn it on once you have automation to run: # you add scripts or install plugins. Turn it on once you have automation to run:
# systemctl --user enable --now punktfunk-scripting # systemctl --user enable --now punktfunk-scripting
# #
# Auto-wired like the console: a plugin's connect() reads the host's mgmt token + identity cert from # Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity
# ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing. # cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env
# editing. The plugin token authorizes the plugin surface but not hook registration or pairing
# administration; a script that needs the admin surface sets PUNKTFUNK_MGMT_TOKEN explicitly.
[Unit] [Unit]
Description=punktfunk plugin/script runner Description=punktfunk plugin/script runner
Documentation=https://git.unom.io/unom/punktfunk Documentation=https://git.unom.io/unom/punktfunk
@@ -29,6 +31,19 @@ RestartSec=2
KillMode=mixed KillMode=mixed
KillSignal=SIGTERM KillSignal=SIGTERM
TimeoutStopSec=30 TimeoutStopSec=30
# Sandbox: free hardening for well-behaved plugins. The filesystem is read-only outside the home
# directory (ReadWritePaths keeps plugin state, download dirs, and ~/.config/punktfunk writable);
# /tmp is private; no setuid re-escalation; sockets limited to what automation actually uses
# (loopback mgmt API, LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME
# (e.g. a library on another mount) gets a drop-in:
# systemctl --user edit punktfunk-scripting → [Service]\nReadWritePaths=/mnt/games
# NOTE: the mount-namespace options (ProtectSystem/PrivateTmp) need unprivileged user namespaces
# for a *user* unit; on kernels/distros that restrict those, drop them via the same drop-in.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=%h
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+51 -11
View File
@@ -13,21 +13,47 @@ export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
/** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */ /** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */
export const pluginsDirDefault = (): string => path.join(configDir(), "plugins"); export const pluginsDirDefault = (): string => path.join(configDir(), "plugins");
export interface ResolveOptions {
/**
* Allow names that resolve on the PUBLIC npm registry (unscoped `punktfunk-plugin-*`, foreign
* scopes, arbitrary paths). Off by default: only the `@punktfunk` scope — pinned to the Gitea
* registry by [`ensureBunfig`] — installs without it, so a typo or a squatted look-alike
* package can't silently pull operator-privileged code from npmjs.org (the CLI flag is
* `--allow-public-registry`).
*/
allowPublicRegistry?: boolean;
}
/** /**
* Resolve a friendly plugin name to its npm package. A bare first-party name maps into the * Resolve a friendly plugin name to its npm package. A bare first-party name maps into the
* `@punktfunk` scope (`playnite` → `@punktfunk/plugin-playnite`, `rom-manager` → * `@punktfunk` scope (`playnite` → `@punktfunk/plugin-playnite`, `rom-manager` →
* `@punktfunk/plugin-rom-manager`); a scoped name (`@…/…`), the unscoped plugin convention * `@punktfunk/plugin-rom-manager`); an `@punktfunk/…` name is used verbatim. Anything else —
* (`punktfunk-plugin-…`), or any name with a `/` is used verbatim. * the unscoped `punktfunk-plugin-…` convention, foreign scopes, registry paths — resolves on
* the public registry and is refused unless [`ResolveOptions.allowPublicRegistry`] is set.
*/ */
export const resolvePackage = (name: string): string => { export const resolvePackage = (
name: string,
opts: ResolveOptions = {},
): string => {
const n = name.trim(); const n = name.trim();
if (!n) throw new Error("empty plugin name"); if (!n) throw new Error("empty plugin name");
if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite if (!n.startsWith("@") && !n.includes("/") && !n.startsWith("punktfunk-plugin-")) {
if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim
if (n.includes("/")) return n; // some other registry path — trust it
return `@punktfunk/plugin-${n}`; // bare first-party name return `@punktfunk/plugin-${n}`; // bare first-party name
}
if (n.startsWith("@punktfunk/")) return n; // first-party scope, pinned to our registry
if (!opts.allowPublicRegistry) {
throw new Error(
`'${n}' would install from the PUBLIC npm registry, not Punktfunk's. Plugins run ` +
"with operator privileges - install only code you trust. If you mean it, re-run " +
"with --allow-public-registry.",
);
}
return n;
}; };
/** Does this resolved package name install from Punktfunk's own (Gitea) registry? */
const isFirstParty = (pkg: string): boolean => pkg.startsWith("@punktfunk/");
/** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */ /** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */
export const ensurePluginsDir = (dir = pluginsDirDefault()): string => { export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -66,7 +92,7 @@ export const ensureBunfig = (dir = pluginsDirDefault()): void => {
} }
}; };
export interface PkgOpts { export interface PkgOpts extends ResolveOptions {
/** Plugins dir. Default `<config_dir>/plugins`. */ /** Plugins dir. Default `<config_dir>/plugins`. */
dir?: string; dir?: string;
/** Line sink for progress. Default stdout. */ /** Line sink for progress. Default stdout. */
@@ -101,12 +127,26 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
}; };
/** Install one or more plugins by friendly name or package. */ /** Install one or more plugins by friendly name or package. */
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
runBun("add", names.map(resolvePackage), opts); const pkgs = names.map((n) => resolvePackage(n, opts));
const log = opts.log ?? ((l: string) => console.log(l));
for (const pkg of pkgs.filter((p) => !isFirstParty(p))) {
log(
`[plugins] WARNING: ${pkg} installs from the public npm registry - it is not ` +
"published by Punktfunk. It will run with operator privileges.",
);
}
runBun("add", pkgs, opts);
};
/** Uninstall one or more plugins by friendly name or package. */ /** Uninstall one or more plugins by friendly name or package. Removal is always safe — a name
* never gates on the registry it once came from. */
export const removePlugins = (names: string[], opts: PkgOpts = {}): void => export const removePlugins = (names: string[], opts: PkgOpts = {}): void =>
runBun("remove", names.map(resolvePackage), opts); runBun(
"remove",
names.map((n) => resolvePackage(n, { allowPublicRegistry: true })),
opts,
);
export interface InstalledPlugin { export interface InstalledPlugin {
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */ /** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
+9 -2
View File
@@ -8,7 +8,9 @@
// //
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …` // With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
// here): // here):
// add <name…> install first-party (playnite, rom-manager) or punktfunk-plugin-* packages // add <name…> install first-party plugins (playnite, rom-manager); anything resolving on
// the PUBLIC npm registry (punktfunk-plugin-*, foreign scopes) additionally
// needs --allow-public-registry
// remove <name…> uninstall // remove <name…> uninstall
// list list installed plugin packages // list list installed plugin packages
// //
@@ -44,7 +46,12 @@ const positionals = (): string[] => {
return out; return out;
}; };
const pkgOpts = { dir: options.pluginsDir }; const pkgOpts = {
dir: options.pluginsDir,
// Opt-in for names that resolve on the public npm registry (supply-chain gate in
// plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own.
allowPublicRegistry: process.argv.includes("--allow-public-registry"),
};
const runPkgOp = ( const runPkgOp = (
op: (names: string[], o: typeof pkgOpts) => void, op: (names: string[], o: typeof pkgOpts) => void,
+19 -3
View File
@@ -37,12 +37,28 @@ describe("resolvePackage", () => {
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager"); expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
}); });
test("passes through scoped, unscoped-convention, and pathed names verbatim", () => { test("passes @punktfunk-scoped names through verbatim (our registry, no gate)", () => {
expect(resolvePackage("@punktfunk/plugin-playnite")).toBe( expect(resolvePackage("@punktfunk/plugin-playnite")).toBe(
"@punktfunk/plugin-playnite", "@punktfunk/plugin-playnite",
); );
expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x"); });
expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom");
test("refuses public-registry names without allowPublicRegistry", () => {
expect(() => resolvePackage("punktfunk-plugin-custom")).toThrow(
/public/i,
);
expect(() => resolvePackage("@someone/plugin-x")).toThrow(/public/i);
expect(() => resolvePackage("some/registry-path")).toThrow(/public/i);
});
test("passes public-registry names through with allowPublicRegistry", () => {
const allow = { allowPublicRegistry: true };
expect(resolvePackage("punktfunk-plugin-custom", allow)).toBe(
"punktfunk-plugin-custom",
);
expect(resolvePackage("@someone/plugin-x", allow)).toBe(
"@someone/plugin-x",
);
}); });
test("trims and rejects empty", () => { test("trims and rejects empty", () => {