diff --git a/apps/skit/src/permissions.rs b/apps/skit/src/permissions.rs index 032250f9..40dfef44 100644 --- a/apps/skit/src/permissions.rs +++ b/apps/skit/src/permissions.rs @@ -162,10 +162,19 @@ impl Permissions { "containers::*".to_string(), // Transport: allow MoQ, deny HTTP fetcher by default (SSRF risk) "transport::moq::*".to_string(), - // Core: explicitly allow safe-ish nodes; deny core::file_writer by default (arbitrary write risk) + // Transport HTTP: allow `mse` (serves over the caller's own request, no + // arbitrary-URL fetch) so least-privilege gateways can serve live casts + // without admin. `transport::http::fetcher` stays denied (SSRF risk). + // The oneshot `streamkit::http_input`/`http_output` markers are not gated by + // this allowlist (the oneshot path treats them as implicitly allowed), so + // they are intentionally not listed here. + "transport::http::mse".to_string(), + // Core: explicitly allow the safe nodes. Omitted on purpose: + // `core::file_writer` / `core::object_store_writer` (arbitrary/external write). "core::passthrough".to_string(), "core::file_reader".to_string(), "core::pacer".to_string(), + "core::param_bridge".to_string(), "core::json_serialize".to_string(), "core::text_chunker".to_string(), "core::script".to_string(), @@ -511,6 +520,20 @@ mod tests { assert!(user.is_node_allowed("plugin::wasm::gain_filter_rust")); } + #[test] + fn test_default_user_http_and_core_node_policy() { + let user = Permissions::user(); + // `mse` serves over the caller's own request and is safe to allow. + assert!(user.is_node_allowed("transport::http::mse")); + // Safe in-graph core nodes (no external side effects) are allowed. + assert!(user.is_node_allowed("core::param_bridge")); + // The HTTP fetcher stays denied (arbitrary-URL fetch / SSRF risk), and the + // write-capable core nodes stay denied (arbitrary / external write). + assert!(!user.is_node_allowed("transport::http::fetcher")); + assert!(!user.is_node_allowed("core::file_writer")); + assert!(!user.is_node_allowed("core::object_store_writer")); + } + #[test] fn test_global_session_limits() { let config = PermissionsConfig { max_concurrent_sessions: Some(10), ..Default::default() }; diff --git a/apps/skit/tests/sample_config_test.rs b/apps/skit/tests/sample_config_test.rs index 88e4902a..324815ed 100644 --- a/apps/skit/tests/sample_config_test.rs +++ b/apps/skit/tests/sample_config_test.rs @@ -50,6 +50,7 @@ fn samples_skit_toml_parses_and_matches_expected_defaults() { assert!(config.permissions.roles.contains_key("admin")); assert!(config.permissions.roles.contains_key("demo")); assert!(config.permissions.roles.contains_key("user")); + assert!(config.permissions.roles.contains_key("gateway")); assert!(config.permissions.roles.contains_key("readonly")); let readonly = config.permissions.get_role("readonly"); @@ -58,5 +59,35 @@ fn samples_skit_toml_parses_and_matches_expected_defaults() { assert!(!readonly.upload_assets); assert!(!readonly.delete_assets); + // The user role mirrors Permissions::user(): the allowed_samples list is + // reachable (sample flags set), the HTTP `mse` sink is allowed, but the + // SSRF-risk fetcher and the write-capable core nodes stay denied. + let user = config.permissions.get_role("user"); + assert!(user.list_samples && user.read_samples && user.write_samples && user.delete_samples); + assert!(user.is_node_allowed("transport::http::mse")); + assert!(user.is_node_allowed("core::param_bridge")); + assert!(!user.is_node_allowed("transport::http::fetcher")); + assert!(!user.is_node_allowed("core::file_writer")); + assert!(!user.is_node_allowed("core::object_store_writer")); + // Asset policy also mirrors the built-in: audio, images and fonts. + assert!(user.is_asset_allowed("samples/audio/system/beep.wav")); + assert!(user.is_asset_allowed("samples/images/system/logo.png")); + assert!(user.is_asset_allowed("samples/fonts/system/inter.ttf")); + + // The gateway role is least-privilege but must actually be able to build the + // servo -> encode -> mux -> serve pipeline. Crucially the plugin kind has to + // pass is_node_allowed() (checked before the plugin allowlist), and the + // fetcher / file_writer stay denied. + let gateway = config.permissions.get_role("gateway"); + assert!(gateway.create_sessions); + assert!(!gateway.load_plugins); + assert!(gateway.is_node_allowed("plugin::native::servo")); + assert!(gateway.is_plugin_allowed("plugin::native::servo")); + assert!(gateway.is_node_allowed("video::vp9::encoder")); + assert!(gateway.is_node_allowed("containers::webm::muxer")); + assert!(gateway.is_node_allowed("transport::http::mse")); + assert!(!gateway.is_node_allowed("transport::http::fetcher")); + assert!(!gateway.is_node_allowed("core::file_writer")); + assert!(config.script.global_fetch_allowlist.is_empty()); } diff --git a/docs/src/content/docs/guides/authorization.md b/docs/src/content/docs/guides/authorization.md index cd689332..ff7921db 100644 --- a/docs/src/content/docs/guides/authorization.md +++ b/docs/src/content/docs/guides/authorization.md @@ -109,6 +109,47 @@ allowed_assets = ["*"] > [!NOTE] > Role permissions are deny-by-default. If you define a custom role in `skit.toml`, any permission you omit defaults to `false`. +> [!NOTE] +> The built-in `user` role allows `transport::http::mse` (live-cast playback over the caller's own request) but **not** `transport::http::fetcher`, which can fetch arbitrary URLs (SSRF risk). The oneshot `streamkit::http_input` / `streamkit::http_output` markers are always permitted on the oneshot path regardless of `allowed_nodes`, so they need no allowlist entry. A trusted gateway that only serves or receives over the caller's own request therefore does not need `admin`. + +## Example: Least-privilege gateway role + +Trusted intermediaries (e.g. the `web-capture` or `speech-gateway` examples) build a small set of fixed pipelines and should run with a scoped token instead of `admin`. The role below grants exactly the node kinds the web-capture pipeline (`servo → encode → mux → serve`) needs and nothing more. + +> [!IMPORTANT] +> A plugin must appear in **both** `allowed_nodes` and `allowed_plugins`. Enforcement calls `is_node_allowed(kind)` *before* the plugin check, so a plugin kind missing from `allowed_nodes` is rejected before `allowed_plugins` is ever consulted. + +```toml +[permissions.roles.gateway] +create_sessions = true +destroy_sessions = true +modify_sessions = true +tune_nodes = true +list_sessions = true +list_nodes = true +access_all_sessions = false # Only its own sessions +load_plugins = false +delete_plugins = false +upload_assets = false +delete_assets = false +allowed_nodes = [ + "plugin::native::servo", # render the page (web-capture) + "video::pixel_convert", # servo RGBA -> encoder input format + "video::vp9::encoder", # encode to VP9 + "containers::webm::muxer", # mux into WebM for MSE / http_output + "transport::http::mse", # serve the live cast to the browser (MSE) + "core::pacer", + "core::sink", + # No core::file_writer (arbitrary-write risk) and no transport::http::fetcher (SSRF). + # The oneshot streamkit::http_output marker is implicitly allowed. +] +allowed_plugins = ["plugin::native::servo"] # must also be listed in allowed_nodes (see note above) +``` + +This role serves a **video-only** WebM cast. To also carry page audio (the `mse` +node advertises `codecs="vp9,opus"`), add the audio encoder — e.g. +`"audio::opus::encoder"` — to `allowed_nodes`. + ## Permission reference | Permission | Description | diff --git a/samples/skit.toml b/samples/skit.toml index 944e93b4..484725d8 100644 --- a/samples/skit.toml +++ b/samples/skit.toml @@ -471,35 +471,101 @@ delete_plugins = false list_nodes = true access_all_sessions = false -# Users can access all samples except admin-only ones +# Sample management (mirrors the built-in Permissions::user()); without these +# flags the allowed_samples list below would be unreachable. +list_samples = true +read_samples = true +write_samples = true +delete_samples = true + +# Users can access standard samples (mirrors the built-in Permissions::user()). allowed_samples = [ "oneshot/*.yml", "oneshot/*.yaml", "dynamic/*.yml", "dynamic/*.yaml", - "demo/*.yml", - "demo/*.yaml", "user/*.yml", "user/*.yaml", ] -# Users can use most nodes except potentially dangerous ones +# Users can use most nodes except potentially dangerous ones. Mirrors the +# built-in Permissions::user() default. Allowed: MoQ + the HTTP `mse` sink +# (serves over the caller's own request). Denied by omission: +# transport::http::fetcher (SSRF), core::file_writer / core::object_store_writer +# (arbitrary/external write). The oneshot streamkit::http_input/http_output +# markers are implicitly allowed on the oneshot path and need no entry here. allowed_nodes = [ "audio::*", - "transport::*", - "core::*", + "video::*", "containers::*", + "transport::moq::*", + "transport::http::mse", + "core::passthrough", + "core::file_reader", + "core::pacer", + "core::param_bridge", + "core::json_serialize", + "core::text_chunker", + "core::script", + "core::telemetry_tap", + "core::telemetry_out", + "core::sink", + "plugin::*", ] -# Users cannot load plugins, so this list is empty -allowed_plugins = [] +# Users cannot load/delete plugins, but may use plugins an admin has already loaded. +allowed_plugins = ["plugin::*"] # Users can list bundled system assets and their uploaded assets +# (mirrors the built-in Permissions::user(): audio, images and fonts). allowed_assets = [ "samples/audio/system/*", "samples/audio/user/*", + "samples/images/system/*", + "samples/images/user/*", + "samples/fonts/system/*", + "samples/fonts/user/*", +] + +[permissions.roles.gateway] +# Least-privilege role for trusted intermediaries that build a small set of +# fixed pipelines, so they need not run as admin. The node/plugin lists below +# are scoped to the web-capture example; a different gateway (e.g. speech-gateway) +# would swap in its own nodes and plugins (audio nodes, whisper/kokoro, etc.). +create_sessions = true +destroy_sessions = true +list_sessions = true +modify_sessions = true +tune_nodes = true +load_plugins = false +delete_plugins = false +list_nodes = true +access_all_sessions = false # Only its own sessions +upload_assets = false +delete_assets = false + +allowed_samples = [] + +# Exactly the node kinds the web-capture / live-cast pipeline needs, nothing more. +# A plugin kind must be allowed here too: is_node_allowed() runs before the +# allowed_plugins check, so a plugin missing from allowed_nodes is rejected first. +# No fetcher (SSRF) and no file_writer (arbitrary write). The oneshot +# streamkit::http_output marker is implicitly allowed and needs no entry. +allowed_nodes = [ + "plugin::native::servo", # render the page (web-capture) + "video::pixel_convert", # servo RGBA -> encoder input format + "video::vp9::encoder", # encode to VP9 + "containers::webm::muxer", # mux into WebM for MSE / http_output + "transport::http::mse", # serve the live cast to the browser (MSE) + "core::pacer", + "core::sink", ] +# Only the specific plugin(s) the gateway needs (example: servo for web-capture). +allowed_plugins = ["plugin::native::servo"] + +allowed_assets = [] + [permissions.roles.readonly] # Read-only role - can only view, not modify create_sessions = false