Skip to content

Commit f526882

Browse files
authored
Merge pull request #193 from httpsphl/fix/crew-isolation
fix(security): keep each crew apart from the others and from Botloft's data
2 parents a78de5c + 1f1b1da commit f526882

14 files changed

Lines changed: 465 additions & 41 deletions

File tree

‎crates/botloftd/src/browser/launch.rs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,20 @@ fn log_text(line: &str) -> String {
226226
text.trim().to_owned()
227227
}
228228

229+
/// The DevTools port written in each profile under `profiles`.
230+
pub(super) fn ports_in(profiles: &Path) -> Vec<u16> {
231+
let Ok(profiles) = std::fs::read_dir(profiles) else {
232+
return Vec::new();
233+
};
234+
profiles
235+
.flatten()
236+
.filter_map(|profile| {
237+
let text = std::fs::read_to_string(profile.path().join(PORT_FILE)).ok()?;
238+
text.lines().next()?.trim().parse().ok()
239+
})
240+
.collect()
241+
}
242+
229243
/// `DevToolsActivePort` holds the port and the browser's WebSocket path, one
230244
/// per line. The file may be read half-written.
231245
fn devtools_url(text: &str) -> Option<String> {
@@ -240,6 +254,23 @@ fn devtools_url(text: &str) -> Option<String> {
240254
mod tests {
241255
use super::*;
242256

257+
#[test]
258+
fn every_profile_gives_its_port() {
259+
let dir = tempfile::tempdir().expect("tempdir");
260+
for (bot, text) in [
261+
("bot_a", "9222\n/devtools/browser/a\n"),
262+
("bot_b", "61000\n"),
263+
] {
264+
std::fs::create_dir(dir.path().join(bot)).expect("profile");
265+
std::fs::write(dir.path().join(bot).join(PORT_FILE), text).expect("port");
266+
}
267+
std::fs::create_dir(dir.path().join("bot_c")).expect("no port yet");
268+
let mut ports = ports_in(dir.path());
269+
ports.sort_unstable();
270+
assert_eq!(ports, [9222, 61000]);
271+
assert!(ports_in(&dir.path().join("missing")).is_empty());
272+
}
273+
243274
#[test]
244275
fn reads_the_port_file() {
245276
assert_eq!(

‎crates/botloftd/src/browser/mod.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ impl Browsers {
161161
}
162162
}
163163

164+
/// The DevTools ports of the bots' browsers, read from every profile:
165+
/// no page may open them (spec 21.5). A stale file only adds a port.
166+
pub fn devtools_ports(&self) -> Vec<u16> {
167+
launch::ports_in(&self.profiles)
168+
}
169+
164170
fn slot<'a>(&self, slots: &'a mut HashMap<BotId, Slot>, bot: &BotId) -> &'a mut Slot {
165171
slots.entry(bot.clone()).or_insert_with(|| Slot {
166172
calls: Arc::default(),

‎crates/botloftd/src/browser/sites.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,44 @@ pub fn site_of(url: &str) -> Option<String> {
107107
(!host.is_empty()).then(|| host.to_owned())
108108
}
109109

110+
/// The port of a loopback address (`localhost`, `127.x.x.x`, `[::1]` or
111+
/// `0.0.0.0`), or the scheme's own when none is written. `None` for any
112+
/// other address.
113+
pub fn loopback_port(url: &str) -> Option<u16> {
114+
let lower = url.to_ascii_lowercase();
115+
let (rest, default) = match lower.strip_prefix("https://") {
116+
Some(rest) => (rest, 443),
117+
None => (lower.strip_prefix("http://")?, 80),
118+
};
119+
let authority = rest.split(['/', '?', '#']).next()?;
120+
let host_port = authority.rsplit('@').next()?;
121+
let (host, port) = match host_port.strip_prefix('[') {
122+
Some(v6) => {
123+
let (host, after) = v6.split_once(']')?;
124+
(host, after.strip_prefix(':'))
125+
}
126+
None => match host_port.split_once(':') {
127+
Some((host, port)) => (host, Some(port)),
128+
None => (host_port, None),
129+
},
130+
};
131+
let host = host.trim_end_matches('.');
132+
let loopback = host == "localhost"
133+
|| host.ends_with(".localhost")
134+
|| host == "::1"
135+
|| host == "0.0.0.0"
136+
|| host
137+
.parse::<std::net::Ipv4Addr>()
138+
.is_ok_and(|ip| ip.is_loopback());
139+
if !loopback {
140+
return None;
141+
}
142+
match port {
143+
None | Some("") => Some(default),
144+
Some(port) => port.parse().ok(),
145+
}
146+
}
147+
110148
/// Whether allowing `allowed` also allows `site`.
111149
pub fn covers(allowed: &str, site: &str) -> bool {
112150
site == allowed

‎crates/botloftd/src/browser/sites_tests.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,17 @@ fn what_the_owner_types_opens_only_as_a_web_address() {
140140
assert_eq!(open("https://"), None);
141141
assert_eq!(open(""), None);
142142
}
143+
144+
#[test]
145+
fn loopback_addresses_give_their_port() {
146+
assert_eq!(loopback_port("http://127.0.0.1:9222/json/list"), Some(9222));
147+
assert_eq!(loopback_port("http://LOCALHOST:3000"), Some(3000));
148+
assert_eq!(loopback_port("http://a@127.1.2.3:4000/"), Some(4000));
149+
assert_eq!(loopback_port("http://[::1]:9222"), Some(9222));
150+
assert_eq!(loopback_port("http://0.0.0.0:80"), Some(80));
151+
assert_eq!(loopback_port("https://app.localhost/x"), Some(443));
152+
assert_eq!(loopback_port("http://localhost"), Some(80));
153+
assert_eq!(loopback_port("http://example.com:9222"), None);
154+
assert_eq!(loopback_port("http://127.0.0.1.example.com:9222"), None);
155+
assert_eq!(loopback_port("file:///C:/x.html"), None);
156+
}

‎crates/botloftd/src/service/bots.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ pub(crate) fn insert(
114114
created_at: now_ms(),
115115
archived_at: None,
116116
};
117-
workspace::prepare_bot(daemon.workspace_env(), crew, &record).map_err(ApiError::Workspace)?;
117+
let crews = store.crews(true)?;
118+
workspace::prepare_bot(daemon.workspace_env(), crew, &crews, &record)
119+
.map_err(ApiError::Workspace)?;
118120
store.insert_bot(&record)?;
119121
Ok(record)
120122
}

‎crates/botloftd/src/service/crews.rs‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub fn create(daemon: &Daemon, params: CrewsCreateParams) -> ApiResult<Crew> {
3232
let chosen = params
3333
.work_folder
3434
.as_deref()
35-
.map(|input| choose_folder(daemon, input))
35+
.map(|input| choose_folder(daemon, &store, &slug, None, input))
3636
.transpose()?;
3737

3838
let lead = params
@@ -85,13 +85,13 @@ pub fn rename(daemon: &Daemon, params: CrewsRenameParams) -> ApiResult<Crew> {
8585
/// Moves the crew to another work folder, or back to `shared`. Each bot
8686
/// restarts to reach it once nothing is in progress (spec 7.4).
8787
pub fn set_work_folder(daemon: &Daemon, params: CrewsSetWorkFolderParams) -> ApiResult<Crew> {
88+
let store = daemon.store();
89+
let mut crew = active(&store, &params.crew_id)?;
8890
let chosen = params
8991
.work_folder
9092
.as_deref()
91-
.map(|input| choose_folder(daemon, input))
93+
.map(|input| choose_folder(daemon, &store, &crew.slug, Some(&crew.id), input))
9294
.transpose()?;
93-
let store = daemon.store();
94-
let mut crew = active(&store, &params.crew_id)?;
9595
if crew.work_folder_chosen == chosen.is_some()
9696
&& chosen
9797
.as_deref()
@@ -176,8 +176,22 @@ pub(crate) fn changed(daemon: &Daemon, crew: Crew) -> Crew {
176176
crew
177177
}
178178

179-
fn choose_folder(daemon: &Daemon, input: &str) -> ApiResult<String> {
180-
let path = workspace::folder::choose(&daemon.paths, input).map_err(ApiError::validation)?;
179+
/// A work folder of the crew `slug` (`id` once it exists), apart from every
180+
/// other crew's folders.
181+
fn choose_folder(
182+
daemon: &Daemon,
183+
store: &Store,
184+
slug: &str,
185+
id: Option<&CrewId>,
186+
input: &str,
187+
) -> ApiResult<String> {
188+
let others: Vec<Crew> = store
189+
.crews(true)?
190+
.into_iter()
191+
.filter(|crew| Some(&crew.id) != id)
192+
.collect();
193+
let path = workspace::folder::choose(&daemon.paths, slug, &others, input)
194+
.map_err(ApiError::validation)?;
181195
Ok(path.display().to_string())
182196
}
183197

‎crates/botloftd/src/service/files/mod.rs‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,38 @@ const PREVIEW_MAX_BYTES: u64 = 20 * 1024 * 1024;
2727
/// Generated for the bot by the daemon: not something it made for the owner.
2828
const WORKSPACE_SKIP: [&str; 2] = ["attachments", "CLAUDE.md"];
2929

30-
/// Where a bot's files live, and what it wrote by name.
30+
/// Where a bot's files live, what it wrote by name, and where its writes
31+
/// never count: Botloft's data and the other crews' folders (spec 7.5).
3132
struct Places {
3233
work: PathBuf,
3334
workspace: PathBuf,
3435
written: Vec<String>,
36+
fenced: Vec<PathBuf>,
37+
}
38+
39+
impl Places {
40+
/// A file the bot wrote, unless it is behind a fence.
41+
fn wrote(&self, path: &Path) -> bool {
42+
!self
43+
.fenced
44+
.iter()
45+
.filter_map(|folder| folder.canonicalize().ok())
46+
.any(|folder| path.starts_with(folder))
47+
}
3548
}
3649

3750
fn places(daemon: &Daemon, bot_id: &BotId) -> ApiResult<(BotRecord, Places)> {
3851
let store = daemon.store();
3952
let bot = bots::find(&store, bot_id)?;
4053
let crew = crews::find(&store, &bot.crew_id)?;
4154
let written = store.written_files(&bot.id, WRITTEN_MAX)?;
55+
let crews = store.crews(true)?;
4256
drop(store);
4357
let places = Places {
4458
work: daemon.paths.work_folder(&crew),
4559
workspace: daemon.paths.bot_workspace(&crew.slug, &bot.slug),
4660
written,
61+
fenced: crate::workspace::fences(&daemon.paths, &crew, &crews),
4762
};
4863
Ok((bot, places))
4964
}
@@ -74,6 +89,7 @@ pub fn list(daemon: &Daemon, params: FilesListParams) -> ApiResult<Vec<BotFile>>
7489
let path = PathBuf::from(path);
7590
if let Ok(meta) = std::fs::metadata(&path)
7691
&& meta.is_file()
92+
&& path.canonicalize().is_ok_and(|real| places.wrote(&real))
7793
{
7894
found.entry(key(&path)).or_insert_with(|| Found {
7995
size: meta.len(),
@@ -141,7 +157,8 @@ fn reach(places: &Places, asked: &Path) -> Result<(PathBuf, std::fs::Metadata),
141157
.written
142158
.iter()
143159
.filter_map(|file| Path::new(file).canonicalize().ok())
144-
.any(|file| file == path);
160+
.any(|file| file == path)
161+
&& places.wrote(&path);
145162
if !asked.is_absolute() || !(inside || written) {
146163
return Err(Refused::Outside);
147164
}

‎crates/botloftd/src/supervisor/spawn.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ pub(super) fn launch_spec(
107107
session: Option<&str>,
108108
) -> io::Result<(SpawnSpec, Launch)> {
109109
// settings.json and the rules are rewritten on every start.
110-
let workspace = workspace::prepare_bot(daemon.workspace_env(), crew, bot)?;
110+
let crews = daemon.store().crews(true).map_err(io::Error::other)?;
111+
let workspace = workspace::prepare_bot(daemon.workspace_env(), crew, &crews, bot)?;
111112
let token = secrets::random_token()?;
112113
let (session, resumed) = match session {
113114
Some(session) => (session.to_owned(), true),

‎crates/botloftd/src/tools/browser_sites.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ use crate::approvals::{self, Answer};
1313
use crate::browser::{Session, sites};
1414
use crate::state::Daemon;
1515

16+
const CONTROL_PORT: &str = "That address is the control port of a browser Botloft runs, which no \
17+
page may open, in any mode.";
18+
19+
/// Whether `url` is the DevTools port of a bot's browser, its own or
20+
/// another crew's (spec 21.5).
21+
fn control_port(daemon: &Daemon, url: &str) -> bool {
22+
sites::loopback_port(url).is_some_and(|port| daemon.browsers.devtools_ports().contains(&port))
23+
}
24+
1625
/// Whether the bot may use `site`, asking the owner the first time when
1726
/// its mode asks first (spec 21.5).
1827
pub(super) async fn allowed(
@@ -23,6 +32,9 @@ pub(super) async fn allowed(
2332
site: &str,
2433
url: &str,
2534
) -> Result<(), String> {
35+
if control_port(daemon, url) {
36+
return Err(CONTROL_PORT.to_owned());
37+
}
2638
if matches!(
2739
bot.mode,
2840
PermissionMode::Auto | PermissionMode::BypassPermissions
@@ -71,6 +83,10 @@ pub(super) async fn page_allowed(
7183
if sites::is_file(url) {
7284
return outside_file(session, bot, url).await;
7385
}
86+
if control_port(daemon, url) {
87+
let _ = session.open("about:blank").await;
88+
return Err(format!("{CONTROL_PORT} It was closed."));
89+
}
7490
match sites::site_of(url) {
7591
Some(site) => allowed(daemon, id, generation, bot, &site, url).await,
7692
None => Ok(()),

‎crates/botloftd/src/workspace/files.rs‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Contents of the files the daemon generates in a bot's workspace.
22
3-
use std::path::Path;
3+
use std::path::{Path, PathBuf};
44
use std::time::Duration;
55

66
use botloft_core::protocol::Crew;
@@ -15,14 +15,19 @@ use crate::paths::permission_rule_path;
1515
const MCP_TIMEOUT_MARGIN: Duration = Duration::from_secs(120);
1616

1717
/// `.claude/settings.json` (spec 7.5): keeps the bot out of the daemon's
18-
/// secrets, and the owner's own Claude Code memory out of the bot. Deny
19-
/// rules apply even in a folder nobody trusted.
20-
pub fn settings_json(botloft_home: &Path, crew_dir: &Path) -> Value {
18+
/// secrets, out of `fenced` (Botloft's data and the other crews' folders,
19+
/// see `fences`) and the owner's own Claude Code memory out of the bot.
20+
/// Deny rules apply even in a folder nobody trusted, and in every mode.
21+
pub fn settings_json(botloft_home: &Path, crew_dir: &Path, fenced: &[PathBuf]) -> Value {
2122
let secrets = permission_rule_path(&botloft_home.join("secrets"));
23+
let mut deny = vec![format!("Read({secrets}/**)")];
24+
for folder in fenced {
25+
let folder = permission_rule_path(folder);
26+
deny.push(format!("Read({folder}/**)"));
27+
deny.push(format!("Edit({folder}/**)"));
28+
}
2229
json!({
23-
"permissions": {
24-
"deny": [format!("Read({secrets}/**)")],
25-
},
30+
"permissions": { "deny": deny },
2631
// Instruction files above the crew's folder are the owner's.
2732
"claudeMdExcludes": memory::excludes_above(crew_dir),
2833
// The bot's memory is the `CLAUDE.md` in its folder. Claude Code's
@@ -215,18 +220,28 @@ mod tests {
215220
use super::*;
216221

217222
#[test]
218-
fn settings_deny_the_secrets_folder_and_nothing_else() {
223+
fn settings_deny_the_secrets_and_the_fenced_folders() {
219224
let home = Path::new(r"C:\Users\ana\AppData\Local\Botloft");
220-
let settings = settings_json(home, Path::new("/ws/site"));
225+
let settings = settings_json(home, Path::new("/ws/site"), &[]);
221226
assert_eq!(
222227
settings["permissions"],
223228
json!({ "deny": ["Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)"] })
224229
);
230+
let blog = PathBuf::from("C:/Users/ana/Botloft/blog");
231+
let settings = settings_json(home, Path::new("/ws/site"), &[blog]);
232+
assert_eq!(
233+
settings["permissions"]["deny"],
234+
json!([
235+
"Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)",
236+
"Read(//c/Users/ana/Botloft/blog/**)",
237+
"Edit(//c/Users/ana/Botloft/blog/**)",
238+
])
239+
);
225240
}
226241

227242
#[test]
228243
fn settings_keep_the_owners_memory_out_of_the_bot() {
229-
let settings = settings_json(Path::new("/data"), Path::new("/ws/site"));
244+
let settings = settings_json(Path::new("/data"), Path::new("/ws/site"), &[]);
230245
assert_eq!(
231246
settings["claudeMdExcludes"],
232247
json!(memory::excludes_above(Path::new("/ws/site")))

0 commit comments

Comments
 (0)