Address response bodies by response id instead of a filesystem path

The body commands took a path from the client, so a token holder could
read any file the process could. They now take a response id and resolve
the location themselves, and the UI never sees a path at all: the desktop
host asks the backend where the file is, and the bridge fetches
/responses/:id/body.

Ephemeral responses (GraphQL introspection) never reach the database, so
resolution falls back to the path send writes them to.
This commit is contained in:
Gregory Schier
2026-08-14 17:02:05 -07:00
parent 99a318224f
commit 448d349af8
18 changed files with 366 additions and 110 deletions
+1
View File
@@ -4,6 +4,7 @@ pub mod import;
pub mod models_ops;
pub mod plugin_events;
pub mod render;
pub mod responses;
pub mod send;
pub use error::Error;
+53
View File
@@ -0,0 +1,53 @@
//! Where response bodies live, from a response id alone.
//!
//! [`send`](crate::send) writes each body to `<response dir>/<response id>`,
//! and every reader has to find it again knowing only the id: commands take an
//! id from the client and never a path, so a path the client chose can never be
//! opened. [`is_response_id`] is the check that makes joining a client-supplied
//! id onto the response directory safe.
use std::path::{Path, PathBuf};
/// The file a response's body is written to, and therefore read back from.
pub fn response_body_path(response_dir: &Path, response_id: &str) -> PathBuf {
response_dir.join(response_id)
}
/// Whether a string is shaped like a response id, and can therefore be joined
/// onto the response directory without escaping it.
///
/// Ids are `rs_<hex>`. Rejecting anything else outright, rather than trying to
/// sanitize it, is what keeps "read the body of response X" from naming a file
/// of the caller's choosing: no separators, no `..`, no absolute paths, no
/// drive letters, no NUL.
pub fn is_response_id(id: &str) -> bool {
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_generated_ids() {
assert!(is_response_id("rs_A1b2C3d4"));
assert!(is_response_id("rs_with-dash"));
}
#[test]
fn rejects_anything_that_could_escape_the_directory() {
for id in [
"",
"..",
"../../etc/passwd",
"rs_1/../../secret",
"/etc/passwd",
r"C:\Windows\System32",
r"rs_1\..\secret",
"rs_1.txt",
"rs_1\0",
] {
assert!(!is_response_id(id), "{id:?} should be rejected");
}
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
use crate::render::render_http_request;
use crate::responses::response_body_path;
use async_trait::async_trait;
use log::warn;
use std::path::{Path, PathBuf};
@@ -660,7 +661,7 @@ pub async fn send_http_request<T: TemplateCallback>(
source,
}
})?;
let body_path = params.response_dir.join(&response.id);
let body_path = response_body_path(params.response_dir, &response.id);
let response_body_path = body_path.to_string_lossy().to_string();
let connected_response = HttpResponse {
state: HttpResponseState::Connected,