forked from 1b-agent/opencadstudio-upstream
fix(automation): TCP automation requires a session token
--http (headless and GUI-hosted) and --serve --port answered anything that reached the loopback port, and the REST responses allowed every web origin. Loopback is shared by every process and user on the machine, so the port proved nothing. - Every REST request now needs Authorization: Bearer <token>, and every --serve --port request line a "token" field; otherwise 401 / unauthorized (the socket is closed). - The token is OCS_API_TOKEN when the launcher sets it, else 32 random bytes printed on stderr at startup; compared in constant time. - No CORS headers are sent, so a web page cannot read an answer. - stdio --serve and --mcp are unchanged (private to their parent / the GUI's own token). GHSA-c27p-xfx2-9r5x Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d0bfa48037
commit
1fec34d84a
7 files changed
+195
-60
No files matched your search
+15
-2
@@ -29,7 +29,8 @@ use super::OpenCADStudio;
|
||||
|
||||
/// Run the headless JSON server. Default transport is stdin/stdout; with
|
||||
/// `--port <N>` it instead listens on `127.0.0.1:<N>` and serves one client at
|
||||
/// a time (the document session persists across reconnects).
|
||||
/// a time (the document session persists across reconnects); every socket
|
||||
/// request carries `"token"` (see `rest::api_token`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn serve() {
|
||||
let mut app = OpenCADStudio::new();
|
||||
@@ -163,6 +164,7 @@ fn serve_socket_with_idle(
|
||||
let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
bound_port.store(bound, std::sync::atomic::Ordering::SeqCst);
|
||||
eprintln!("OpenCADStudio --serve listening on 127.0.0.1:{bound}");
|
||||
crate::rest::announce_token();
|
||||
for stream in listener.incoming().flatten() {
|
||||
let _ = stream.set_read_timeout(Some(idle));
|
||||
let Ok(read_half) = stream.try_clone() else {
|
||||
@@ -178,7 +180,18 @@ fn serve_socket_with_idle(
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let resp = app.automation_op(line);
|
||||
// Loopback is shared by every process and user on the machine:
|
||||
// each request carries the session token, like the GUI's own
|
||||
// channel, and a connection that fails it is dropped.
|
||||
let mut request: Value = serde_json::from_str(line).unwrap_or(Value::Null);
|
||||
if !crate::rest::token_matches(request["token"].as_str()) {
|
||||
let _ = writeln!(writer, "{}", crate::rest::unauthorized());
|
||||
break;
|
||||
}
|
||||
if let Some(object) = request.as_object_mut() {
|
||||
object.remove("token");
|
||||
}
|
||||
let resp = app.automation_op(&request.to_string());
|
||||
if writeln!(writer, "{resp}").is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
//! through the same `rest::plan` table as the headless server; requests are
|
||||
//! forwarded into the GUI's automation queue (`Envelope`, exactly like the
|
||||
//! native bridge in `transport`) and the JSON response is written back over
|
||||
//! HTTP. The channel binds loopback only — anything that can reach the port
|
||||
//! can drive the session, exactly like the headless server.
|
||||
//! HTTP. The channel binds loopback only and, like the headless server,
|
||||
//! answers only requests carrying the session's bearer token
|
||||
//! ([`rest::api_token`]): loopback is shared by every process and user on
|
||||
//! the machine, so reaching the port proves nothing.
|
||||
|
||||
use super::{Envelope, Reply};
|
||||
use crate::rest::{self, HttpRequest, Plan, RETRYABLE};
|
||||
@@ -58,6 +60,7 @@ fn listen(
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
bound_port.store(listener.local_addr()?.port(), Ordering::SeqCst);
|
||||
eprintln!("OpenCADStudio GUI REST listening on http://127.0.0.1:{port}/api/v1");
|
||||
rest::announce_token();
|
||||
// One thread per connection: a `getpoint` or `user_select` request stays
|
||||
// open until the person answers, and must not block the other reads
|
||||
// (get_selection…).
|
||||
@@ -79,8 +82,8 @@ fn listen(
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(15)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(15)));
|
||||
if let Ok(Some(request)) = rest::read_request(&mut stream) {
|
||||
if request.method == "OPTIONS" {
|
||||
let _ = rest::write_response(&mut stream, 204, &Value::Null);
|
||||
if !request.authorized() {
|
||||
let _ = rest::write_response(&mut stream, 401, &rest::unauthorized());
|
||||
} else {
|
||||
let (status, body) = forward(&request, &sender, &document_cache, &stream);
|
||||
let _ = rest::write_response(&mut stream, status, &body);
|
||||
@@ -353,7 +356,8 @@ mod tests {
|
||||
fn post(port: u16, target: &str, body: &str) -> std::net::TcpStream {
|
||||
let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
let http = format!(
|
||||
"POST {target} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n{}",
|
||||
"POST {target} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {}\r\nContent-Length: {}\r\n\r\n{}",
|
||||
rest::api_token(),
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
@@ -447,7 +451,10 @@ mod tests {
|
||||
/// GET without a body — the read half of the REST surface.
|
||||
fn get(port: u16, target: &str) -> std::net::TcpStream {
|
||||
let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
let http = format!("GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n");
|
||||
let http = format!(
|
||||
"GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {}\r\n\r\n",
|
||||
rest::api_token()
|
||||
);
|
||||
stream.write_all(http.as_bytes()).unwrap();
|
||||
stream
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user