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:
@@ -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
@@ -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`. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user