feat(security): scope the plugin runner to a capability-limited bearer token

The scripting runner used to hold the console's full-admin mgmt-token — a
plugin defect could rewrite hooks.json (arbitrary command execution) or
administer pairing. The host now mints a second persisted secret,
plugin-token (PUNKTFUNK_PLUGIN_TOKEN), and require_auth grows a third lane
for it: loopback-confined like the admin token, but plugin_may_access
carves out /hooks (read AND write), everything under /pair, /native/pair,
/native/pending, client unpair DELETEs, and other plugins' ui-credential.
Everything a plugin legitimately does (status/library/events/sessions,
its own UI lease) is untouched.

The SDK's zero-config connect() now prefers PUNKTFUNK_PLUGIN_TOKEN /
plugin-token over mgmt-token, so plugins pick the scoped credential up
automatically; a script that genuinely needs the admin surface sets
PUNKTFUNK_MGMT_TOKEN explicitly. Old hosts without a plugin-token fall
back to mgmt-token unchanged. No OpenAPI change: the lane is auth-layer
only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:20:09 +02:00
parent ecec7cc062
commit 9b5ca0eff3
6 changed files with 269 additions and 35 deletions
+4
View File
@@ -498,6 +498,10 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
if opts.token.is_none() {
opts.token = Some(crate::mgmt_token::load_or_generate()?);
}
// The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the
// admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin
// surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access).
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
+11
View File
@@ -56,6 +56,10 @@ pub struct Options {
/// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated,
/// which [`run`] only permits on loopback binds.
pub token: Option<String>,
/// The scripting runner's capability-limited bearer token (`plugin-token`): authorizes the
/// plugin surface only, never hook registration or pairing administration
/// (`auth::plugin_may_access`). Optional — `None` simply disables the lane.
pub plugin_token: Option<String>,
}
impl Default for Options {
@@ -63,6 +67,7 @@ impl Default for Options {
Options {
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
token: None,
plugin_token: None,
}
}
}
@@ -81,6 +86,9 @@ pub(crate) struct MgmtState {
/// (native-only) host, where a Moonlight PIN can never arrive.
gamestream_enabled: bool,
token: Option<String>,
/// The plugin lane's token (see [`Options::plugin_token`]). Checked only after the admin token
/// mismatches, and gated by `auth::plugin_may_access` per route.
plugin_token: Option<String>,
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
port: u16,
}
@@ -119,6 +127,7 @@ pub async fn run(
let app = app(
state,
Some(token),
opts.plugin_token.filter(|t| !t.trim().is_empty()),
opts.bind.port(),
native,
stats,
@@ -131,6 +140,7 @@ pub async fn run(
fn app(
state: Arc<AppState>,
token: Option<String>,
plugin_token: Option<String>,
port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
@@ -142,6 +152,7 @@ fn app(
stats,
gamestream_enabled,
token,
plugin_token,
port,
});
let (api_routes, api) = api_router_parts();
+54 -1
View File
@@ -1,5 +1,12 @@
//! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere)
//! or the bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
//! or a bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
//!
//! Three lanes, three authorities:
//! - **paired streaming cert** (mTLS, LAN) — the read-only [`cert_may_access`] allowlist.
//! - **plugin token** (bearer, loopback) — the scripting runner's capability-limited credential:
//! the admin surface MINUS hook registration and pairing administration
//! ([`plugin_may_access`]).
//! - **admin token** (bearer, loopback) — everything.
use super::shared::*;
use crate::gamestream::tls::PeerAddr;
@@ -86,6 +93,27 @@ pub(crate) async fn require_auth(
.and_then(|v| v.strip_prefix("Bearer "));
match presented {
Some(token) if token_eq(token, expected) => next.run(req).await,
// The scripting runner's scoped lane: same loopback confinement as the admin token, but
// routes that would let a plugin escalate — registering hooks (arbitrary command
// execution as the host user) or administering pairing (admitting/ejecting devices,
// reading the PIN) — need the operator's admin token. Checked AFTER the admin token so
// equal tokens (operator misconfiguration) degrade to full access, never to a lockout.
Some(token)
if st
.plugin_token
.as_deref()
.is_some_and(|pt| token_eq(token, pt)) =>
{
if plugin_may_access(req.method(), req.uri().path()) {
next.run(req).await
} else {
api_error(
StatusCode::FORBIDDEN,
"this route is not authorized for the plugin token — it requires the \
operator's admin token",
)
}
}
_ => api_error(
StatusCode::UNAUTHORIZED,
"missing or invalid credentials (a paired client cert, or a bearer token)",
@@ -93,6 +121,31 @@ pub(crate) async fn require_auth(
}
}
/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the
/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives
/// sessions, and registers its UI lease), with these carve-outs:
/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary
/// command execution as the host user, and reading it can expose webhook credentials.
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
/// *which devices may stream*; a plugin defect must not be able to admit an attacker's device
/// or eject the operator's.
/// - **UI proxy credentials** — a plugin has no business reading another plugin's per-boot UI
/// secret; only the console proxy (admin token) needs it.
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
let denied = path == "/api/v1/hooks"
|| path == "/api/v1/pair"
|| path.starts_with("/api/v1/pair/")
|| path == "/api/v1/native/pair"
|| path.starts_with("/api/v1/native/pair/")
|| path == "/api/v1/native/pending"
|| path.starts_with("/api/v1/native/pending/")
|| (method == Method::DELETE
&& (path.starts_with("/api/v1/clients/")
|| path.starts_with("/api/v1/native/clients/")))
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"));
!denied
}
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
/// safe, read-only status routes only. Deny-by-default — every state-changing route and every route
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
+131
View File
@@ -42,6 +42,8 @@ fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
app(
state,
Some(token.unwrap_or("test-secret").to_string()),
// The scoped plugin lane, exercised by the `plugin_token_*` tests below.
Some("plugin-secret".to_string()),
DEFAULT_PORT,
None,
stats,
@@ -57,6 +59,7 @@ fn test_app_native(state: Arc<AppState>, np: Arc<crate::native_pairing::NativePa
app(
state,
Some("test-secret".to_string()),
Some("plugin-secret".to_string()),
DEFAULT_PORT,
Some(np),
stats,
@@ -374,6 +377,133 @@ async fn bearer_token_is_enforced() {
);
}
/// The pure route gate for the plugin lane: exclusion-based, so spot-check both sides — the
/// surface a plugin legitimately uses, and every escalation carve-out.
#[test]
fn plugin_allowlist_excludes_escalation_routes() {
use axum::http::Method;
// The legitimate plugin surface stays open (including mutations — sessions, library, leases).
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/library"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients"));
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/plugins"));
assert!(auth::plugin_may_access(
&Method::PUT,
"/api/v1/plugins/rom-manager"
));
assert!(auth::plugin_may_access(
&Method::DELETE,
"/api/v1/plugins/rom-manager"
));
// Hooks: registration is command execution; even the read can expose webhook credentials.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/hooks"));
assert!(!auth::plugin_may_access(&Method::PUT, "/api/v1/hooks"));
// Pairing administration + PIN visibility.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/pair"));
assert!(!auth::plugin_may_access(&Method::POST, "/api/v1/pair/pin"));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/native/pair"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/native/pair/arm"
));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/native/pending"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/native/pending/1/approve"
));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/clients/aabbcc"
));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/native/clients/aabbcc"
));
// Another plugin's UI proxy secret.
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/plugins/x/ui-credential"
));
}
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
/// and the same loopback confinement as the admin token.
#[tokio::test]
async fn plugin_token_lane_is_scoped_and_loopback_only() {
use axum::http::Method;
let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret"
let plugin_req = |method: Method, path: &str| {
axum::http::Request::builder()
.method(method)
.uri(path)
.header("authorization", "Bearer plugin-secret")
.body(Body::empty())
.unwrap()
};
// The plugin surface authenticates: status + the plugin directory (list and lease removal).
assert_eq!(
send(&app, plugin_req(Method::GET, "/api/v1/status"))
.await
.0,
StatusCode::OK
);
assert_eq!(
send(&app, plugin_req(Method::GET, "/api/v1/plugins"))
.await
.0,
StatusCode::OK
);
assert_eq!(
send(
&app,
plugin_req(Method::DELETE, "/api/v1/plugins/no-such-plugin")
)
.await
.0,
StatusCode::NO_CONTENT
);
// The carve-outs answer 403 (authenticated but not authorized), not 401.
for (method, path) in [
(Method::GET, "/api/v1/hooks"),
(Method::PUT, "/api/v1/hooks"),
(Method::GET, "/api/v1/pair"),
(Method::POST, "/api/v1/native/pair/arm"),
(Method::GET, "/api/v1/native/pending"),
(Method::DELETE, "/api/v1/clients/aabbcc"),
(Method::GET, "/api/v1/plugins/x/ui-credential"),
] {
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
assert!(body["error"].as_str().unwrap().contains("plugin token"));
}
// A wrong token never reaches the lane.
let wrong = axum::http::Request::get("/api/v1/status")
.header("authorization", "Bearer plugin-wrong")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, wrong).await.0, StatusCode::UNAUTHORIZED);
// Loopback-only, exactly like the admin token: a LAN peer is refused before token compare.
let mut lan = plugin_req(Method::GET, "/api/v1/status");
lan.extensions_mut()
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
assert_eq!(send(&app, lan).await.0, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn host_info_reports_identity_and_ports() {
let app = test_app(test_state(), None);
@@ -544,6 +674,7 @@ async fn blank_token_rejected() {
let opts = Options {
bind: "127.0.0.1:0".parse().unwrap(),
token: Some(" ".into()),
plugin_token: None,
};
let err = run(test_state(), opts, None, test_stats(), false)
.await
+53 -29
View File
@@ -1,12 +1,19 @@
//! Management-API bearer token resolution.
//!
//! The mgmt API always serves HTTPS (the host's identity cert) and now always requires auth — even
//! on a loopback bind. This module guarantees a token always exists: an explicit
//! `PUNKTFUNK_MGMT_TOKEN` env wins (operator override, not persisted); otherwise the persisted
//! `~/.config/punktfunk/mgmt-token` is used; otherwise a fresh 32-byte hex token is generated and
//! persisted. The file is written in `PUNKTFUNK_MGMT_TOKEN=<hex>` form (0600) so the bundled web
//! console can source it directly as a systemd `EnvironmentFile` — a single source of truth shared
//! between the host and the console with no copying.
//! on a loopback bind. This module guarantees the tokens always exist: an explicit env var wins
//! (operator override, not persisted); otherwise the persisted file under the config dir is used;
//! otherwise a fresh 32-byte hex token is generated and persisted. Files are written in
//! `KEY=<hex>` form (0600) so the bundled web console can source them directly as a systemd
//! `EnvironmentFile` — a single source of truth shared between the host and its consumers.
//!
//! Two tokens, two authorities:
//! - **`mgmt-token`** (`PUNKTFUNK_MGMT_TOKEN`) — the operator/console token; authorizes the full
//! admin surface.
//! - **`plugin-token`** (`PUNKTFUNK_PLUGIN_TOKEN`) — the scripting runner's capability-limited
//! credential (`mgmt::auth::plugin_may_access`): everything a plugin legitimately needs, but not
//! hook registration or pairing administration. The SDK's `connect()` prefers this file, so a
//! defect in an operator plugin can't rewrite `hooks.json` or admit new devices.
use anyhow::{Context, Result};
use rand::RngCore;
@@ -15,19 +22,32 @@ use std::path::Path;
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
const FILE: &str = "mgmt-token";
const PLUGIN_ENV_VAR: &str = "PUNKTFUNK_PLUGIN_TOKEN";
const PLUGIN_FILE: &str = "plugin-token";
/// Resolve the mgmt token (env > persisted file > generate+persist). Hex (not base64) so the
/// persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
/// Resolve the mgmt (full-admin) token (env > persisted file > generate+persist). Hex (not base64)
/// so the persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
pub fn load_or_generate() -> Result<String> {
if let Ok(v) = std::env::var(ENV_VAR) {
load_or_generate_impl(ENV_VAR, FILE)
}
/// Resolve the scripting runner's scoped plugin token, same precedence as [`load_or_generate`].
/// Persisted to `plugin-token` next to `mgmt-token`; on Windows `plugins enable` grants the
/// runner's LocalService principal read on exactly this file (and `cert.pem`) — never `mgmt-token`.
pub fn load_or_generate_plugin() -> Result<String> {
load_or_generate_impl(PLUGIN_ENV_VAR, PLUGIN_FILE)
}
fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
if let Ok(v) = std::env::var(env_var) {
let v = v.trim();
if !v.is_empty() {
return Ok(v.to_string());
}
}
let path = pf_paths::config_dir().join(FILE);
let path = pf_paths::config_dir().join(file);
if let Ok(contents) = fs::read_to_string(&path) {
if let Some(tok) = parse_token(&contents) {
if let Some(tok) = parse_token(&contents, env_var) {
return Ok(tok);
}
}
@@ -37,29 +57,29 @@ pub fn load_or_generate() -> Result<String> {
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
write_token(&path, &token)?;
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
write_token(&path, env_var, &token)?;
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
Ok(token)
}
/// Parse the token from the persisted file: accept either a bare token line or a
/// `PUNKTFUNK_MGMT_TOKEN=<token>` line (the form we write, also valid as an EnvironmentFile).
fn parse_token(contents: &str) -> Option<String> {
/// `<KEY>=<token>` line (the form we write, also valid as an EnvironmentFile).
fn parse_token(contents: &str, env_var: &str) -> Option<String> {
let line = contents.lines().find(|l| !l.trim().is_empty())?.trim();
let tok = line
.strip_prefix("PUNKTFUNK_MGMT_TOKEN=")
.strip_prefix(env_var)
.and_then(|rest| rest.strip_prefix('='))
.unwrap_or(line)
.trim();
(!tok.is_empty()).then(|| tok.to_string())
}
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path` as an owner-only secret — 0600 on Unix AND
/// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so
/// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the
/// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review
/// 2026-06-28 #2).
fn write_token(path: &Path, token: &str) -> Result<()> {
let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n");
/// Write `<KEY>=<token>` to `path` as an owner-only secret — 0600 on Unix AND DACL-locked to
/// SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so both bearer
/// tokens get the SAME Windows lockdown as the host key; the bespoke `cfg(unix)`-only writer used
/// to leave the mgmt token readable by any local user (security-review 2026-06-28 #2).
fn write_token(path: &Path, env_var: &str, token: &str) -> Result<()> {
let line = format!("{env_var}={token}\n");
pf_paths::write_secret_file(path, line.as_bytes())
.with_context(|| format!("write {}", path.display()))
}
@@ -70,13 +90,17 @@ mod tests {
#[test]
fn parses_bare_and_keyvalue_forms() {
assert_eq!(parse_token("abc123\n").as_deref(), Some("abc123"));
assert_eq!(parse_token("abc123\n", ENV_VAR).as_deref(), Some("abc123"));
assert_eq!(
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(),
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n", ENV_VAR).as_deref(),
Some("deadbeef")
);
assert_eq!(parse_token("\n \n"), None);
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None);
assert_eq!(
parse_token("PUNKTFUNK_PLUGIN_TOKEN=deadbeef\n", PLUGIN_ENV_VAR).as_deref(),
Some("deadbeef")
);
assert_eq!(parse_token("\n \n", ENV_VAR), None);
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n", ENV_VAR), None);
}
#[test]
@@ -84,9 +108,9 @@ mod tests {
let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let path = dir.join(FILE);
write_token(&path, "cafef00d").unwrap();
write_token(&path, ENV_VAR, "cafef00d").unwrap();
let read = fs::read_to_string(&path).unwrap();
assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
assert_eq!(parse_token(&read, ENV_VAR).as_deref(), Some("cafef00d"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
+16 -5
View File
@@ -2,9 +2,17 @@
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
// needs zero configuration.
//
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (admin override), else PUNKTFUNK_PLUGIN_TOKEN,
// else <config_dir>/plugin-token, else <config_dir>/mgmt-token
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
//
// Token precedence is deliberate: the host mints a capability-limited `plugin-token` for the
// scripting runner (it cannot register hooks or administer pairing), and that is what a plugin's
// zero-config connect() should hold — the full-admin `mgmt-token` is only a fallback for hosts
// that predate the plugin token (and on Windows the runner's LocalService principal can't read it
// at all). A script that legitimately needs the admin surface sets PUNKTFUNK_MGMT_TOKEN or passes
// { token } explicitly.
//
// The CA is the host's own identity certificate — trusting exactly it (not the system roots)
// IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch,
@@ -73,11 +81,14 @@ export const resolveConfig = async (
const token =
options?.token ??
process.env.PUNKTFUNK_MGMT_TOKEN ??
process.env.PUNKTFUNK_PLUGIN_TOKEN ??
parseTokenFile(readIfExists(path.join(configDir(), "plugin-token")) ?? "") ??
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? "");
if (!token) {
throw new Error(
"no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
"no management token: set PUNKTFUNK_PLUGIN_TOKEN (or PUNKTFUNK_MGMT_TOKEN), pass " +
"{ token }, or run where the host's token files exist " +
`(${path.join(configDir(), "plugin-token")})`,
);
}
const caPath = process.env.PUNKTFUNK_MGMT_CA;