Compare commits

...

2 commits

Author SHA1 Message Date
Breadway
e073a17353 ci: package bread-emit and bread-module-host on all tracks
Some checks failed
check / check (push) Failing after 33s
dev release / build (push) Failing after 1m7s
bakery.toml already lists all four binaries; copy, hash, and GitHub
Release upload them. release.yml clones bread-ecosystem via mktemp like
dev/rc so concurrent runner jobs do not race on /tmp/bread-ecosystem-ci.
2026-08-23 14:38:52 +08:00
Breadway
3865327c66 fix(module-host): canonicalize path grants and exec without a shell
RPC-side path_allowed now canonicalizes (parent+filename for new files)
and uses Path::starts_with so ../.ssh and /Wallpapers-evil cannot ride a
Wallpapers grant. exec/exec_capture parse argv, reject shell
metacharacters, and spawn Command::new instead of sh -c. Reserve the
daemon-synthesized module/state/widget/reload event families.
2026-08-23 14:38:52 +08:00
5 changed files with 232 additions and 45 deletions

View file

@ -61,7 +61,7 @@ jobs:
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/bread/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadd bread; do
for bin in breadd bread bread-emit bread-module-host; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \

View file

@ -32,7 +32,7 @@ jobs:
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/bread/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadd bread; do
for bin in breadd bread bread-emit bread-module-host; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \

View file

@ -28,7 +28,7 @@ jobs:
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bread/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadd bread; do
for bin in breadd bread bread-emit bread-module-host; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
@ -48,9 +48,13 @@ jobs:
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' release
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"
- name: upload to GitHub Release
env:
@ -64,6 +68,10 @@ jobs:
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread \
"${PKG_DIR}/breadd-x86_64" \
"${PKG_DIR}/bread-x86_64" \
"${PKG_DIR}/bread-emit-x86_64" \
"${PKG_DIR}/bread-module-host-x86_64" \
"${PKG_DIR}/breadd-x86_64.sha256" \
"${PKG_DIR}/bread-x86_64.sha256" \
"${PKG_DIR}/bread-emit-x86_64.sha256" \
"${PKG_DIR}/bread-module-host-x86_64.sha256" \
--clobber

View file

@ -32,7 +32,9 @@ pub const KNOWN_APPS: &[&str] = &[
/// `workspace`, `window`, and `monitor` added (event families the Hyprland
/// and Bluetooth adapters already published under, but that were missing
/// from this list) when this became a spoofing-prevention boundary and not
/// just an app-id-conflict one. Since: v1.7 — command-bus exception.*
/// just an app-id-conflict one. Since: v1.7 — command-bus exception.
/// `module`, `state`, `widget`, and `reload` are daemon-synthesized
/// families and must stay unclaimable.*
const RESERVED_DOMAINS: &[&str] = &[
"terminal",
"git",
@ -53,6 +55,10 @@ const RESERVED_DOMAINS: &[&str] = &[
"workspace",
"window",
"monitor",
"module",
"state",
"widget",
"reload",
];
/// Whether `id` is a registered sibling-app id.
@ -174,6 +180,10 @@ mod tests {
"monitor",
"window",
"system",
"module",
"state",
"widget",
"reload",
] {
assert!(
is_reserved_domain(domain),

View file

@ -25,6 +25,7 @@
//! directly from Lua bypasses every check in this file — that's the
//! scenario the sandbox exists for, not this file.
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::time::Duration;
use bread_shared::{glob, ModuleHostHello, ModuleHostPush, ModulePermission, PermissionKind};
@ -349,10 +350,7 @@ impl Server {
Ok(json!({ "ok": true }))
}
"module_host.fs_read" => {
if !permissions
.iter()
.any(|p| p.kind == PermissionKind::FsRead)
{
if !permissions.iter().any(|p| p.kind == PermissionKind::FsRead) {
return Err("fs.read not granted to this module".to_string());
}
let path = req
@ -411,16 +409,17 @@ impl Server {
.and_then(Value::as_str)
.ok_or("missing cmd")?
.to_string();
if !bin_allowed(permissions, &cmd) {
return Err("command is outside this module's granted exec bin scope".to_string());
}
let argv = exec_argv(permissions, &cmd)?;
tokio::task::spawn_blocking(move || {
match std::process::Command::new("sh").arg("-c").arg(&cmd).status() {
match std::process::Command::new(&argv[0])
.args(&argv[1..])
.status()
{
Ok(status) if !status.success() => {
warn!(cmd = %cmd, code = ?status.code(), "module_host.exec exited non-zero");
warn!(cmd = %argv[0], code = ?status.code(), "module_host.exec exited non-zero");
}
Err(e) => {
error!(cmd = %cmd, error = %e, "module_host.exec failed to spawn");
error!(cmd = %argv[0], error = %e, "module_host.exec failed to spawn");
}
_ => {}
}
@ -437,17 +436,16 @@ impl Server {
.and_then(Value::as_str)
.ok_or("missing cmd")?
.to_string();
if !bin_allowed(permissions, &cmd) {
return Err("command is outside this module's granted exec bin scope".to_string());
}
let argv = exec_argv(permissions, &cmd)?;
let timeout_ms = req
.params
.get("timeout_ms")
.and_then(Value::as_u64)
.unwrap_or(2000);
let handle =
tokio::task::spawn_blocking(move || {
std::process::Command::new("sh").arg("-c").arg(&cmd).output()
let handle = tokio::task::spawn_blocking(move || {
std::process::Command::new(&argv[0])
.args(&argv[1..])
.output()
});
match tokio::time::timeout(Duration::from_millis(timeout_ms + 500), handle).await {
Ok(Ok(Ok(out))) => Ok(json!({
@ -474,7 +472,9 @@ impl Server {
(
ModuleLoadState::LoadError,
ModuleHostOutcome::LoadError(
error.clone().unwrap_or_else(|| "module load failed".to_string()),
error
.clone()
.unwrap_or_else(|| "module load failed".to_string()),
),
)
};
@ -501,13 +501,14 @@ impl Server {
/// Belt-and-suspenders path scoping for `fs_read`/`fs_write`: if the
/// manifest declared a `path` hint for this permission kind, the requested
/// path (after `~`-expansion) must fall under at least one granted prefix.
/// No hint at all means this RPC-level check stays permissive (matching
/// Workstream D's existing "un-hinted grant = ungated within that
/// namespace" behavior) — Landlock's own ruleset (built independently in
/// `module_host::apply_sandbox`) does NOT grant a filesystem rule for an
/// un-hinted permission, so the direct `os`/`io` escape hatch remains
/// kernel-denied for that case regardless of what this function returns.
/// path (after `~`-expansion and canonicalize) must be a real descendant
/// of at least one granted prefix. No hint at all means this RPC-level
/// check stays permissive (matching Workstream D's existing "un-hinted
/// grant = ungated within that namespace" behavior) — Landlock's own
/// ruleset (built independently in `module_host::apply_sandbox`) does NOT
/// grant a filesystem rule for an un-hinted permission, so the direct
/// `os`/`io` escape hatch remains kernel-denied for that case regardless
/// of what this function returns.
fn path_allowed(permissions: &[ModulePermission], kind: PermissionKind, path: &str) -> bool {
let hints: Vec<&String> = permissions
.iter()
@ -517,18 +518,93 @@ fn path_allowed(permissions: &[ModulePermission], kind: PermissionKind, path: &s
if hints.is_empty() {
return true;
}
let expanded = bread_shared::expand_path(path);
let Some(requested) = resolve_scoped_path(path) else {
return false;
};
hints.iter().any(|hint| {
let hint_expanded = bread_shared::expand_path(hint);
expanded.starts_with(&hint_expanded)
let Some(granted) = resolve_scoped_path(hint) else {
return false;
};
// Path::starts_with is component-wise; str::starts_with would let
// `/Wallpapers-evil` match a `/Wallpapers` grant.
requested.starts_with(&granted)
})
}
/// Same idea as [`path_allowed`] for `exec`'s `bin` hint: compares by file
/// name (so `bin = "hyprpaper"` matches a command invoking
/// `/usr/bin/hyprpaper` as well as a bare `hyprpaper`) or an exact leading
/// token match.
fn bin_allowed(permissions: &[ModulePermission], cmd: &str) -> bool {
/// Canonicalize for containment: existing paths resolve symlinks and `..`;
/// new files canonicalize the parent then re-join the filename. Anything
/// that still contains `..` after lexical normalization is rejected.
fn resolve_scoped_path(path: &str) -> Option<PathBuf> {
let expanded = bread_shared::expand_path(path);
if let Ok(canon) = expanded.canonicalize() {
return Some(canon);
}
let parent = expanded.parent().filter(|p| !p.as_os_str().is_empty());
if let (Some(parent), Some(name)) = (parent, expanded.file_name()) {
if let Ok(parent_canon) = parent.canonicalize() {
return Some(parent_canon.join(name));
}
}
lexical_abs(&expanded)
}
fn lexical_abs(path: &Path) -> Option<PathBuf> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::path::absolute(path).ok()?
};
let mut out = PathBuf::new();
for c in abs.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
return None;
}
}
rest => out.push(rest),
}
}
if out.components().any(|c| matches!(c, Component::ParentDir)) {
return None;
}
Some(out)
}
/// Split `cmd` into argv without a shell. Metacharacters are rejected so a
/// hinted binary cannot smuggle extra commands (`hyprpaper; curl evil`).
fn parse_exec_argv(cmd: &str) -> Option<Vec<String>> {
if cmd.chars().any(|c| {
matches!(
c,
'|' | ';' | '&' | '$' | '`' | '\n' | '\r' | '<' | '>' | '(' | ')'
)
}) {
return None;
}
let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
if argv.is_empty() {
None
} else {
Some(argv)
}
}
/// Parse `cmd` and enforce the exec `bin` hint against argv[0].
fn exec_argv(permissions: &[ModulePermission], cmd: &str) -> Result<Vec<String>, String> {
let argv = parse_exec_argv(cmd)
.ok_or_else(|| "command contains shell metacharacters or is empty".to_string())?;
if !bin_allowed(permissions, &argv[0]) {
return Err("command is outside this module's granted exec bin scope".to_string());
}
Ok(argv)
}
/// Same idea as [`path_allowed`] for `exec`'s `bin` hint: compares argv[0]
/// by file name (so `bin = "hyprpaper"` matches `/usr/bin/hyprpaper` as
/// well as a bare `hyprpaper`) or an exact path match.
fn bin_allowed(permissions: &[ModulePermission], program: &str) -> bool {
let hints: Vec<&String> = permissions
.iter()
.filter(|p| p.kind == PermissionKind::Exec)
@ -537,16 +613,109 @@ fn bin_allowed(permissions: &[ModulePermission], cmd: &str) -> bool {
if hints.is_empty() {
return true;
}
let first_word = cmd.split_whitespace().next().unwrap_or("");
let cmd_leaf = std::path::Path::new(first_word)
let cmd_leaf = Path::new(program)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(first_word);
.unwrap_or(program);
hints.iter().any(|hint| {
let hint_leaf = std::path::Path::new(hint.as_str())
let hint_leaf = Path::new(hint.as_str())
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(hint.as_str());
cmd_leaf == hint_leaf || first_word == hint.as_str()
cmd_leaf == hint_leaf || program == hint.as_str()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fs_grant(kind: PermissionKind, path: &str) -> Vec<ModulePermission> {
vec![ModulePermission {
kind,
path: Some(path.to_string()),
bin: None,
}]
}
fn exec_grant(bin: &str) -> Vec<ModulePermission> {
vec![ModulePermission {
kind: PermissionKind::Exec,
path: None,
bin: Some(bin.to_string()),
}]
}
#[test]
fn path_allowed_denies_dotdot_escape_from_granted_prefix() {
let tmp = tempfile::TempDir::new().unwrap();
let wallpapers = tmp.path().join("Wallpapers");
std::fs::create_dir(&wallpapers).unwrap();
let granted = wallpapers.to_str().unwrap();
let perms = fs_grant(PermissionKind::FsRead, granted);
let escape = wallpapers.join("../../.ssh/id_rsa");
assert!(
!path_allowed(&perms, PermissionKind::FsRead, escape.to_str().unwrap()),
"Wallpapers/../../.ssh/id_rsa must not match a Wallpapers grant"
);
}
#[test]
fn path_allowed_denies_string_prefix_sibling() {
let tmp = tempfile::TempDir::new().unwrap();
let wallpapers = tmp.path().join("Wallpapers");
let evil = tmp.path().join("Wallpapers-evil");
std::fs::create_dir(&wallpapers).unwrap();
std::fs::create_dir(&evil).unwrap();
let secret = evil.join("secret");
std::fs::write(&secret, "x").unwrap();
let perms = fs_grant(PermissionKind::FsRead, wallpapers.to_str().unwrap());
assert!(!path_allowed(
&perms,
PermissionKind::FsRead,
secret.to_str().unwrap()
));
}
#[test]
fn path_allowed_accepts_real_descendant_and_new_file() {
let tmp = tempfile::TempDir::new().unwrap();
let wallpapers = tmp.path().join("Wallpapers");
std::fs::create_dir(&wallpapers).unwrap();
let existing = wallpapers.join("bg.png");
std::fs::write(&existing, "x").unwrap();
let perms = fs_grant(PermissionKind::FsWrite, wallpapers.to_str().unwrap());
assert!(path_allowed(
&perms,
PermissionKind::FsWrite,
existing.to_str().unwrap()
));
let new_file = wallpapers.join("new.png");
assert!(path_allowed(
&perms,
PermissionKind::FsWrite,
new_file.to_str().unwrap()
));
}
#[test]
fn exec_argv_denies_shell_chaining_when_bin_hinted() {
let perms = exec_grant("hyprpaper");
assert!(exec_argv(&perms, "hyprpaper; curl evil").is_err());
assert!(exec_argv(&perms, "hyprpaper").is_ok());
assert!(exec_argv(&perms, "/usr/bin/hyprpaper --config x").is_ok());
assert!(exec_argv(&perms, "curl evil").is_err());
}
#[test]
fn parse_exec_argv_rejects_metacharacters() {
assert!(parse_exec_argv("hyprpaper; curl evil").is_none());
assert!(parse_exec_argv("hyprpaper | curl evil").is_none());
assert!(parse_exec_argv("hyprpaper && curl evil").is_none());
assert_eq!(
parse_exec_argv("hyprpaper --config x"),
Some(vec!["hyprpaper".into(), "--config".into(), "x".into()])
);
}
}