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
File diff suppressed because one or more lines are too long
+75 -16
View File
@@ -30,6 +30,7 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::export::{self, ExportDataParams};
use yaak::responses::{is_response_id, response_body_path};
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -1020,27 +1021,64 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
}
}
/// Where a response's body is, and what it is meant to be read as.
struct ResponseBodyLocation {
/// None when the response has no stored body.
path: Option<PathBuf>,
/// The response's declared `Content-Type`, empty when it has none. An
/// ephemeral response has no headers to consult, so its body decodes as
/// UTF-8.
content_type: String,
}
/// Find a response's body from its id alone.
///
/// The frontend hands back an id and never a path, so nothing a caller says can
/// point this at a file the engine didn't write. Persisted responses carry the
/// path they were written to; ephemeral ones (GraphQL introspection) never
/// reach the database, but their body still lands at `<response dir>/<id>`,
/// which is how [`yaak::send`] builds the path in the first place.
fn locate_response_body<R: Runtime>(
app_handle: &AppHandle<R>,
response_id: &str,
) -> YaakResult<ResponseBodyLocation> {
if !is_response_id(response_id) {
return Err(GenericError(format!("Invalid response ID {response_id}")));
}
match app_handle.db().get_http_response(response_id) {
Ok(response) => Ok(ResponseBodyLocation {
path: response.body_path.map(PathBuf::from),
content_type: response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone())
.unwrap_or_default(),
}),
Err(yaak_models::error::Error::ModelNotFound(_)) => {
let response_dir = app_handle.path().app_data_dir()?.join("responses");
Ok(ResponseBodyLocation {
path: Some(response_body_path(&response_dir, response_id)),
content_type: String::new(),
})
}
Err(e) => Err(e.into()),
}
}
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response: HttpResponse,
response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
let body_path = match response.body_path {
None => {
return Ok(FilterResponse { content: String::new(), error: None });
}
Some(p) => p,
let location = locate_response_body(window.app_handle(), response_id)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
let content_type = response
.headers
.iter()
.find_map(|h| {
if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None }
})
.unwrap_or_default();
let content_type = location.content_type.as_str();
let body = read_response_body(&body_path, content_type)
.await
.ok_or(GenericError("Failed to find response body".to_string()))?;
@@ -1053,6 +1091,20 @@ async fn cmd_http_response_body<R: Runtime>(
}
}
/// The body's path on this machine, for the desktop host to read or hand to the
/// webview's asset protocol.
///
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
/// the path, and only because it is about to open the file itself. Hosts
/// without a filesystem serve the same bytes over HTTP instead.
async fn cmd_http_response_body_path<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<String>> {
let location = locate_response_body(&app_handle, response_id)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
async fn cmd_http_request_body<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
@@ -1069,8 +1121,15 @@ async fn cmd_http_request_body<R: Runtime>(
Ok(Some(body))
}
async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> {
let body = fs::read(file_path)?;
async fn cmd_get_sse_events<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<ServerSentEvent>> {
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
return Ok(Vec::new());
};
let body = fs::read(body_path)?;
let mut event_parser = EventParser::new();
event_parser.process_bytes(body.into())?;
+17 -5
View File
@@ -268,10 +268,17 @@ pub(crate) struct CmdFormatGraphqlReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpResponseBodyReq {
pub response: HttpResponse,
pub response_id: String,
pub filter: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpResponseBodyPathReq {
pub response_id: String,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
@@ -283,7 +290,7 @@ pub(crate) struct CmdHttpRequestBodyReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdGetSseEventsReq {
pub file_path: String,
pub response_id: String,
}
#[derive(Debug, Deserialize, TS)]
@@ -983,15 +990,19 @@ async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphq
}
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), req.response, req.filter.as_deref()).await?)
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?)
}
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_get_sse_events<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
Ok(crate::cmd_get_sse_events(&req.file_path).await?)
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
@@ -1371,6 +1382,7 @@ rpc_commands! {
cmd_format_json(CmdFormatJsonReq) -> String,
cmd_format_graphql(CmdFormatGraphqlReq) -> String,
cmd_http_response_body(CmdHttpResponseBodyReq) -> FilterResponse,
cmd_http_response_body_path(CmdHttpResponseBodyPathReq) -> Option<String>,
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,