Plug-ins: a permission grant covers only the module that was approved

A grant records the module's SHA-256, but only its permissions were
compared, so a module with other bytes under a granted id inherited the
grant when it listed no new permission.

A grant now covers a module only when the id, the SHA-256 and every
listed permission match. plugin.inspect reports needsApproval for a
changed module, plugin.install refuses it without a new grant and leaves
the installed module and its grant as they are, Install Plug-in and
File > Open ask again, and the plug-in store and plug-in folders skip it.
The same bytes install again without asking, and a module with no
permissions list or an empty one needs no grant. A grant whose hash is
not 64 lower-case hexadecimal digits covers nothing.

plugin.install still trusts the client that sends grant; the docs say so.
This commit is contained in:
dotCooCoo
2026-11-08 08:40:00 -08:00
parent 0a702cc0a5
commit 91d9639e6a
9 changed files with 232 additions and 47 deletions
+41 -8
View File
@@ -199,6 +199,11 @@ pub fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// Is `s` a SHA-256 as [`sha256_hex`] writes it: 64 lower-case hexadecimal digits?
fn is_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved
/// ([`sha256_hex`]) and the permissions it was granted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -209,9 +214,10 @@ pub struct Grant {
}
impl Grant {
/// Does it grant every one of `wanted`?
pub fn covers(&self, wanted: &[Permission]) -> bool {
wanted.iter().all(|w| self.permissions.contains(w))
/// Does it grant every one of `wanted` to the module with this `id` and SHA-256 `hash`? A grant
/// whose own hash is not a valid SHA-256 ([`sha256_hex`]) covers nothing.
pub fn covers(&self, id: &str, hash: &str, wanted: &[Permission]) -> bool {
self.id == id && is_sha256_hex(&self.hash) && self.hash == hash && wanted.iter().all(|w| self.permissions.contains(w))
}
}
@@ -281,10 +287,37 @@ mod tests {
#[test]
fn a_grant_covers_its_permissions_and_no_more() {
let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
assert!(g.covers(&[Permission::DocRead]));
assert!(g.covers(&[]));
assert!(!g.covers(&[Permission::State]));
assert!(!g.covers(&[Permission::DocRead, Permission::State]));
let hash = sha256_hex(b"module");
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
assert!(g.covers("a", &hash, &[Permission::DocRead]));
assert!(g.covers("a", &hash, &[]));
assert!(!g.covers("a", &hash, &[Permission::State]));
assert!(!g.covers("a", &hash, &[Permission::DocRead, Permission::State]));
}
#[test]
fn a_grant_covers_only_the_module_that_was_approved() {
let hash = sha256_hex(b"module");
let wanted = [Permission::DocRead];
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead] };
assert!(g.covers("a", &hash, &wanted));
assert!(!g.covers("b", &hash, &wanted), "another id");
assert!(!g.covers("a", &sha256_hex(b"other"), &wanted), "other bytes");
assert!(!g.covers("a", &sha256_hex(b"other"), &[]), "other bytes, even when nothing is wanted");
}
/// Damaged data can't stand for every module, even when the hash asked about is just as damaged.
#[test]
fn a_grant_with_an_invalid_hash_covers_nothing() {
let upper = sha256_hex(b"module").to_uppercase();
let short = "ab".repeat(31);
let long = "ab".repeat(33);
let not_hex = "g".repeat(64);
let wide = "é".repeat(32);
for hash in ["", "00", upper.as_str(), short.as_str(), long.as_str(), not_hex.as_str(), wide.as_str()] {
let g = Grant { id: "a".into(), hash: hash.into(), permissions: vec![Permission::DocRead] };
assert!(!g.covers("a", hash, &[Permission::DocRead]), "{hash:?}");
assert!(!g.covers("a", hash, &[]), "{hash:?}");
}
}
}
+4 -4
View File
@@ -38,11 +38,11 @@ pub fn revoke(id: &str) -> bool {
}
/// May `plugin` be installed without asking the user: does it list no permissions, or did the user
/// already grant its id every permission it lists? A module with other bytes but no new permission
/// needs no new approval.
/// already grant this very module (its id and SHA-256) every permission it lists? A module with
/// other bytes needs a new approval, even when it lists the same permissions.
pub fn approved(plugin: &Plugin) -> bool {
let m = plugin.manifest();
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions()))
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions()))
}
/// Installs `plugin`, replacing one with the same id.
@@ -125,7 +125,7 @@ pub struct FolderReport {
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]),
/// which installs only once the user agrees, and one that lists permissions the user has not
/// granted its id ([`approved`]).
/// granted this very module ([`approved`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
load_folder_where(dir, |_| Ok(()), approved)