From 1fec34d84a129a2e4c6466e184893f78ae596777 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 30 Sep 2026 20:30:05 +0300 Subject: [PATCH] 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 , 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 --- docs/automation/API-SPEC.md | 32 +++++---- docs/automation/README.md | 59 +++++++++-------- docs/automation/rest_smoke.py | 8 ++- src/app/automation.rs | 17 ++++- src/app/control/http_bridge.rs | 19 ++++-- src/rest.rs | 114 ++++++++++++++++++++++++++++----- src/rest_openapi.json | 6 ++ 7 files changed, 195 insertions(+), 60 deletions(-) diff --git a/docs/automation/API-SPEC.md b/docs/automation/API-SPEC.md index d175f8bdb..c124f6b31 100644 --- a/docs/automation/API-SPEC.md +++ b/docs/automation/API-SPEC.md @@ -72,15 +72,22 @@ MCP advertise the whole surface; feature detection is `capabilities` (§4). `OpenCADStudio --http 8090` runs a private, window-less session behind a resource-oriented REST surface at `http://127.0.0.1:/api/v1` (loopback -bind only, permissive CORS, JSON bodies, one request per connection). A +bind only, JSON bodies, one request per connection, no CORS headers). A machine-readable OpenAPI 3 description of the whole surface is served at `GET /api/v1/openapi`. +Every request carries `Authorization: Bearer `: the value of +`OCS_API_TOKEN` when the launcher set it, otherwise a random token printed on +stderr at startup. A request without it is answered **401** +`{"code":"unauthorized"}`. Loopback is shared by every process and user on +the machine, so the port alone is no credential. + | Status | Meaning | |---|---| | `200` | Success (read or update) | | `201` | A resource was created (document, entities, block, group, selection set, layout) | | `400` | Validation failure — the body keeps the symbolic `code` and message | +| `401` | Missing or wrong bearer token (`code:"unauthorized"`) | | `404` | Unknown route or unknown handle (`code:"entity_absent"`) | | `409` | State conflict (`stale_state`, `document_not_active`) or GUI required (`gui_required`) | | `503` | Dispatcher busy | @@ -126,7 +133,7 @@ Differences worth knowing: - Unknown POST paths are treated as op attempts and forwarded (any op of the surface works — including `operation` to poll a pending pick and `cancel` to retract it); truly unrouted paths answer `404 {"code":"unknown_route"}`. -- `OPTIONS *` answers **204** (CORS preflight). +- The same bearer token as §3.1 is required on every request. ### 3.3 stdio JSONL (`--serve`) @@ -138,6 +145,9 @@ envelope; mutations use the envelope of §1). ### 3.4 TCP (`--serve --port N`) The same JSONL protocol as §3.3 over `127.0.0.1:`, one client at a time. +Every request line carries `"token":""` (the §3.1 token); a line +without it is answered `{"ok":false,"code":"unauthorized"}` and the +connection is closed. ### 3.5 AI-tool session (`--mcp`) @@ -506,7 +516,7 @@ client's own log. ```sh # Create (missing layers auto-created; the batch is atomic). -curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{ "entities":[ {"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"}, {"type":"Text","value":"PAGE-01","position":[10,50],"height":3.0} @@ -514,16 +524,16 @@ curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: applicat # → 201 {"result":{"handles":["63","64"],"created":2,"layers_created":["FRAME"]}} # Filter by property. -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D" # Mark, wrap into a reusable block, plot every sheet, save as a template. -curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \ -H "Content-Type: application/json" -d '[{"code":1000,"value":"PAGE-01"}]' -curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \ -H "Content-Type: application/json" -d '{"name":"MARK","base":[0,0,0],"handles":["63","64"]}' -curl -s -X POST http://127.0.0.1:8090/api/v1/plot \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \ -H "Content-Type: application/json" -d '{"path":"C:/out/pages.pdf","layout":"all","per_page":true}' -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}' ``` @@ -534,14 +544,14 @@ OpenCADStudio.exe "C:/drawings/plan.dwg" --http 8090 # the editor stays intera # Ask the person to pick block references; the connection parks until they # press Enter (or Esc). No polling loop needed. -curl -s -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \ -H "Content-Type: application/json" \ -d '{"request_id":"sample-1","type":"INSERT","detail":"full","prompt":"Pick the title blocks"}' # → {"ok":true,"status":"completed","result":{"cancelled":false,"count":2,…}} # Read the same entities back, then continue driving the live drawing. -curl -s -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}' -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}' +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \ -H "Content-Type: application/json" \ -d '{"handles":["6F"],"action":"move","vector":[5,0]}' ``` diff --git a/docs/automation/README.md b/docs/automation/README.md index 5a50320c5..183a8973f 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -369,13 +369,22 @@ editor, events and plotting. resource-oriented REST surface on `http://127.0.0.1:/api/v1`. Any HTTP client works — curl, Python, C# `HttpClient`, JS `fetch`; there is no SDK and nothing AI-specific. The server keeps one drawing session alive -across requests, binds loopback only, sends permissive CORS headers, and -serves its machine-readable description at `GET /api/v1/openapi` -(OpenAPI 3, embedded from `src/rest_openapi.json`). +across requests, binds loopback only, and serves its machine-readable +description at `GET /api/v1/openapi` (OpenAPI 3, embedded from +`src/rest_openapi.json`). + +Every request carries `Authorization: Bearer `; anything else is +answered `401 {"code":"unauthorized"}`. Loopback is shared by every process +and user on the machine, so the port alone proves nothing. Set the token +yourself with `OCS_API_TOKEN` before launching, or read the random one the +server prints on stderr at startup. No CORS headers are sent, so a web page +cannot drive or read the session. The same token guards `--serve --port` +(as a `"token"` field in each JSON line) and the GUI-hosted channel below. ```sh +export OCS_API_TOKEN=$(openssl rand -hex 32) OpenCADStudio --http 8090 # start the REST server (headless) -curl http://127.0.0.1:8090/api/v1/ready +curl -H "Authorization: Bearer $OCS_API_TOKEN" http://127.0.0.1:8090/api/v1/ready ``` ### GUI-hosted REST channel (`--http` + file) @@ -503,7 +512,7 @@ HTTP. ```sh # 1. Create entities (the missing layer "FRAME" is created automatically; # the whole batch commits as one undoable step or not at all). -curl -s -X POST http://127.0.0.1:8090/api/v1/entities \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities \ -H "Content-Type: application/json" \ -d '{"entities":[ {"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"}, @@ -514,71 +523,71 @@ curl -s -X POST http://127.0.0.1:8090/api/v1/entities \ # → 201 {"ok":true,"status":"completed","result":{"handles":["63","64","65","66"],…}} # 2. Verify: query it back. -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full" # → {"ok":true,"entities":[{"handle":"63","start":[0,0,0],"end":[100,0,0],…},…] # 3. Move the circle 10 units right. -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \ -H "Content-Type: application/json" \ -d '{"handles":["66"],"action":"move","vector":[10,0]}' # 4. Mark the text with extended data (RegApp "SPM" registered implicitly). -curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \ -H "Content-Type: application/json" \ -d '[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":3}]' -curl -s "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM" # 5. Turn the two frame lines into a block definition + Insert. -curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \ -H "Content-Type: application/json" \ -d '{"name":"FRAME-MARK","base":[0,0,0],"handles":["63","67"]}' # → 201 {"result":{"block":"FRAME-MARK","insert":"6B"}} # 6. Save and prove persistence. -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwg"}' # 7. Filter entities by any property (RFC 6901 pointers, SQL-ish operators). -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D" # 8. Set and read back drawing sysvars. -curl -s -X POST http://127.0.0.1:8090/api/v1/sysvars \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/sysvars \ -H "Content-Type: application/json" -d '{"set":{"ltscale":2.5}}' -curl -s "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext" # 9. Provision a sheet: create the layout, write its page setup, then # plot every layout to its own PDF (one file per entry in result.files). -curl -s -X POST http://127.0.0.1:8090/api/v1/layouts \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/layouts \ -H "Content-Type: application/json" -d '{"name":"PLAN"}' -curl -s -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \ -H "Content-Type: application/json" \ -d '{"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}' -curl -s -X POST http://127.0.0.1:8090/api/v1/plot \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \ -H "Content-Type: application/json" \ -d '{"path":"C:/out/plan.pdf","layout":"all","per_page":true}' # 10. Save the drawing as a template (a .dwt is DWG bytes — same writer, # no lock held on the file) and start a fresh drawing from it. -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}' -curl -s -X POST http://127.0.0.1:8090/api/v1/documents \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents \ -H "Content-Type: application/json" -d '{"template":"C:/out/session.dwt"}' # 11. Second document, then copy entities across documents. -curl -s -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \ -H "Content-Type: application/json" \ -d '{"handles":["63","67"],"document_id":2}' # 12. Group the copies, save them as a named selection set, recall it. -curl -s -X POST http://127.0.0.1:8090/api/v1/groups \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/groups \ -H "Content-Type: application/json" -d '{"name":"FRAME","handles":["63","67"]}' -curl -s -X POST http://127.0.0.1:8090/api/v1/selection-sets \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/selection-sets \ -H "Content-Type: application/json" -d '{"name":"frame-set","handles":["63","67"]}' -curl -s "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true" # 13. Close a document, discarding unsaved changes. -curl -s -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true" ``` The same lifecycle — create → verify → transform → xdata → block → diff --git a/docs/automation/rest_smoke.py b/docs/automation/rest_smoke.py index aa0cda0a2..37bdcbd14 100644 --- a/docs/automation/rest_smoke.py +++ b/docs/automation/rest_smoke.py @@ -14,6 +14,8 @@ sets, and a close-with-discard. Uses only the Python standard library. """ import json +import os +import secrets import subprocess import sys import tempfile @@ -24,10 +26,13 @@ import urllib.request from pathlib import Path +TOKEN = secrets.token_hex(32) + + def call(port: int, method: str, path: str, body=None): """One HTTP call; returns (status, parsed-json-or-None).""" data = None - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"} if body is not None: data = json.dumps(body).encode() request = urllib.request.Request( @@ -67,6 +72,7 @@ def main() -> None: out_dir = Path(tempfile.mkdtemp(prefix="ocs_rest_smoke_")) process = subprocess.Popen( [str(exe), "--http", str(port)], + env={**os.environ, "OCS_API_TOKEN": TOKEN}, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) diff --git a/src/app/automation.rs b/src/app/automation.rs index 2a1fcee33..d4feb67e3 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -29,7 +29,8 @@ use super::OpenCADStudio; /// Run the headless JSON server. Default transport is stdin/stdout; with /// `--port ` it instead listens on `127.0.0.1:` 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; } diff --git a/src/app/control/http_bridge.rs b/src/app/control/http_bridge.rs index b62856b33..b1f387423 100644 --- a/src/app/control/http_bridge.rs +++ b/src/app/control/http_bridge.rs @@ -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 } diff --git a/src/rest.rs b/src/rest.rs index d494ad796..e387831c5 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -9,7 +9,11 @@ //! the identical handlers with identical validation, undo and idempotency. //! //! Conventions: one request per connection (`Connection: close`), JSON -//! bodies, permissive CORS for local browser clients, loopback bind only. +//! bodies, loopback bind only, and every request carries +//! `Authorization: Bearer ` (see [`api_token`]). Loopback is shared by +//! every process and user on the machine, and a web page can aim requests at +//! it too, so the port alone proves nothing; no CORS headers are sent, so a +//! browser page cannot read an answer either. //! Connections are served one thread each: a slow op (plot, open a large //! drawing) or a client that connects and stalls must never keep other //! callers — `state`, `get_selection`, `cancel`, `operation` — from being @@ -102,11 +106,62 @@ pub fn serve(port: u16) { listen(OpenCADStudio::new(), port, Arc::new(AtomicU16::new(0))); } +/// The secret every TCP automation client presents: `OCS_API_TOKEN` when the +/// launcher set one (so a script knows it in advance), otherwise 32 random +/// bytes printed once on stderr when a listener starts. The GUI's private +/// channel keeps its own per-session token in the user-only descriptor. +pub fn api_token() -> &'static str { + static TOKEN: std::sync::OnceLock = std::sync::OnceLock::new(); + TOKEN.get_or_init(|| { + if let Some(token) = std::env::var("OCS_API_TOKEN") + .ok() + .filter(|token| !token.trim().is_empty()) + { + return token.trim().to_owned(); + } + let mut secret = [0u8; 32]; + getrandom::fill(&mut secret).expect("system random source"); + secret.iter().map(|byte| format!("{byte:02x}")).collect() + }) +} + +/// Tells a listener's user how to authenticate. A token the launcher chose +/// is not echoed back. +pub(crate) fn announce_token() { + if std::env::var("OCS_API_TOKEN").map_or(true, |token| token.trim().is_empty()) { + eprintln!("API token (or set OCS_API_TOKEN before launch): {}", api_token()); + } +} + +/// Whether `presented` is the session's token, compared in constant time. +pub(crate) fn token_matches(presented: Option<&str>) -> bool { + let expected = api_token().as_bytes(); + let Some(presented) = presented.map(str::as_bytes) else { + return false; + }; + presented.len() == expected.len() + && presented + .iter() + .zip(expected) + .fold(0u8, |difference, (a, b)| difference | (a ^ b)) + == 0 +} + +/// The answer to a request without the session's token. +pub(crate) fn unauthorized() -> Value { + json!({ + "ok": false, + "code": "unauthorized", + "error": "Send the session token (printed at startup, or OCS_API_TOKEN): Authorization: Bearer over HTTP, \"token\" in each --serve --port request", + }) +} + pub(crate) struct HttpRequest { pub(crate) method: String, pub(crate) path: String, query: Vec<(String, String)>, body: Vec, + bearer: Option, } impl HttpRequest { @@ -120,6 +175,10 @@ impl HttpRequest { pub(crate) fn json(&self) -> Value { serde_json::from_slice(&self.body).unwrap_or(Value::Null) } + + pub(crate) fn authorized(&self) -> bool { + token_matches(self.bearer.as_deref()) + } } /// One request handed from a connection thread to the dispatcher that owns @@ -144,6 +203,7 @@ fn listen(mut app: OpenCADStudio, port: u16, bound_port: Arc) { let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port); bound_port.store(bound, Ordering::SeqCst); eprintln!("OpenCADStudio REST listening on http://127.0.0.1:{bound}/api/v1"); + announce_token(); let (jobs, incoming) = std::sync::mpsc::channel::(); // Connections are served one thread each: a slow op (plot, open a large // drawing) or a client that connects and stalls must never keep other @@ -185,8 +245,8 @@ fn serve_connection(mut stream: TcpStream, jobs: std::sync::mpsc::Sender) { let Ok(Some(request)) = read_request(&mut stream) else { return; }; - if request.method == "OPTIONS" { - let _ = write_response(&mut stream, 204, &Value::Null); + if !request.authorized() { + let _ = write_response(&mut stream, 401, &unauthorized()); return; } let (reply, answer) = std::sync::mpsc::channel(); @@ -243,6 +303,7 @@ pub(crate) fn read_request(stream: &mut TcpStream) -> std::io::Result std::io::Result MAX_BODY { return Ok(Some(HttpRequest { @@ -272,6 +341,7 @@ pub(crate) fn read_request(stream: &mut TcpStream) -> std::io::Result std::io::Result "Created", 204 => "No Content", 400 => "Bad Request", + 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", @@ -337,7 +409,7 @@ pub(crate) fn write_response(stream: &mut TcpStream, status: u16, body: &Value) _ => "OK", }; let header = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); stream.write_all(header.as_bytes())?; @@ -733,6 +805,7 @@ mod tests { path: path.to_string(), query: Vec::new(), body: body.as_bytes().to_vec(), + bearer: None, } } @@ -763,19 +836,30 @@ mod tests { } assert_ne!(port, 0, "listener never bound a port"); + let status = |authorization: &str| { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .write_all( + format!("GET /api/v1/ready HTTP/1.1\r\nHost: 127.0.0.1\r\n{authorization}\r\n") + .as_bytes(), + ) + .unwrap(); + let mut status_line = String::new(); + BufReader::new(stream) + .read_line(&mut status_line) + .expect("readiness answered"); + status_line + }; // Stalls the thread that accepted it: no request line, ever. let _stalled = TcpStream::connect(("127.0.0.1", port)).unwrap(); - let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - stream - .write_all(b"GET /api/v1/ready HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") - .unwrap(); - let mut reader = BufReader::new(stream); - let mut status_line = String::new(); - reader.read_line(&mut status_line).expect("readiness answered"); - assert!(status_line.contains("200"), "{status_line}"); + let answered = status(&format!("Authorization: Bearer {}\r\n", api_token())); + assert!(answered.contains("200"), "{answered}"); + // Anything else on the machine that finds the port is turned away. + assert!(status("").contains("401")); + assert!(status("Authorization: Bearer guess\r\n").contains("401")); } #[test] diff --git a/src/rest_openapi.json b/src/rest_openapi.json index d3fae4eb1..8325140b0 100644 --- a/src/rest_openapi.json +++ b/src/rest_openapi.json @@ -6,6 +6,7 @@ "description": "REST surface over the OpenCAD Studio automation dispatcher. The server runs headless via `OpenCADStudio --http `, binds 127.0.0.1 only, and keeps one drawing session alive across requests. All bodies are JSON; failure bodies carry a symbolic `code` (see the automation README for the catalogue)." }, "servers": [{"url": "http://127.0.0.1:8090"}], + "security": [{"bearer": []}], "paths": { "/api/v1/ready": { "get": { @@ -255,5 +256,10 @@ "responses": {"200": {"description": "Operation response"}} } } + }, + "components": { + "securitySchemes": { + "bearer": {"type": "http", "scheme": "bearer", "description": "OCS_API_TOKEN, or the token printed on stderr at startup."} + } } }