Required the API token for every runtime API route (#1045)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-11 10:07:07 +02:00
1 parent 4f966eed86
commit 8badadb35f
24 files changed
+231 -79

No files matched your search

+137 -7
View File
@@ -1,11 +1,13 @@
use log::info;
use once_cell::sync::Lazy;
use axum::extract::DefaultBodyLimit;
use axum::middleware;
use axum::routing::{delete, get, post};
use axum::Router;
use axum_server::tls_rustls::RustlsConfig;
use std::net::SocketAddr;
use std::sync::Once;
use crate::api_token::APIToken;
use crate::runtime_certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY};
use crate::environment::is_dev;
use crate::network::get_available_port;
@@ -28,13 +30,9 @@ pub static API_SERVER_PORT: Lazy<u16> = Lazy::new(|| {
}
});
/// Starts the runtime API server. The server is used to communicate with the .NET server and
/// to provide additional functionality to the Tauri app.
pub fn start_runtime_api() {
let api_port = *API_SERVER_PORT;
info!("Try to start the API server on 'http://localhost:{api_port}'...");
let app = Router::new()
/// Creates the router with every route of the runtime API, each of which requires the API token.
fn create_router() -> Router {
let router = Router::new()
.route("/system/dotnet/port", get(crate::dotnet::dotnet_port))
.route("/system/dotnet/ready", get(crate::dotnet::dotnet_ready))
.route("/system/qdrant-edge/info", get(crate::qdrant_edge_database::qdrant_edge_info))
@@ -91,6 +89,29 @@ pub fn start_runtime_api() {
.route("/shortcuts/suspend", post(crate::app_window::suspend_shortcuts))
.route("/shortcuts/resume", post(crate::app_window::resume_shortcuts));
require_api_token(router)
}
/// Requires a valid API token for every route of the given router.
///
/// This layer is the only place where the runtime API checks the token, so the handlers take no
/// `APIToken` argument. Asking each handler for it left a route open to every process on this
/// machine as soon as one handler forgot it.
///
/// `route_layer` protects only the routes registered before it, so call this function after the
/// last route. Unknown paths still answer 404 without checking the token.
fn require_api_token(router: Router) -> Router {
router.route_layer(middleware::from_extractor::<APIToken>())
}
/// Starts the runtime API server. The server is used to communicate with the .NET server and
/// to provide additional functionality to the Tauri app.
pub fn start_runtime_api() {
let api_port = *API_SERVER_PORT;
info!("Try to start the API server on 'http://localhost:{api_port}'...");
let app = create_router();
tauri::async_runtime::spawn(async move {
install_rustls_crypto_provider();
@@ -159,3 +180,112 @@ pub(crate) mod test_support {
response
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use regex::Regex;
use tokio::time::timeout;
use tower::ServiceExt;
use crate::runtime_api_token::API_TOKEN;
/// How long a route may take to refuse a request without the token. The refusal needs no work,
/// so a route which takes longer has passed the request on to its handler. In a test, some
/// handlers then never answer, for example one which waits for the main window, and others
/// panic because the app around them is missing.
const REFUSAL_LIMIT: Duration = Duration::from_secs(1);
/// One route which `create_router` registers.
struct RegisteredRoute {
method: String,
path: String,
}
/// Reads every route from the source of `create_router`, so that a new route is checked
/// without anybody having to add it to a test.
fn registered_routes() -> Vec<RegisteredRoute> {
let source = include_str!("runtime_api.rs");
let start = source.find("fn create_router()").unwrap();
let end = start + source[start..].find("\n}").unwrap();
let create_router_source = &source[start..end];
let route_pattern = Regex::new(r#"\.route\("([^"]+)",\s*(get|post|put|patch|delete)\("#).unwrap();
let path_parameter_pattern = Regex::new(r"\{[^}]+\}").unwrap();
let routes: Vec<RegisteredRoute> = route_pattern
.captures_iter(create_router_source)
.map(|route| RegisteredRoute {
method: route[2].to_uppercase(),
path: path_parameter_pattern.replace_all(&route[1], "x").into_owned(),
})
.collect();
//
// A route which the pattern cannot read would silently drop out of the check, for example
// one whose methods are not a plain get, post, put, patch, or delete call. Extend the
// pattern then.
//
assert_eq!(
routes.len(),
create_router_source.matches(".route(").count(),
"The test could not read every route of create_router.",
);
assert!(!routes.is_empty(), "The test found no route in create_router.");
routes
}
/// Sends one request through the router, without a server, and returns the status of the response.
async fn send(router: Router, request: Request<Body>) -> StatusCode {
router.oneshot(request).await.unwrap().status()
}
/// Builds a request with the given token header, or without one.
fn request(method: &str, uri: &str, token: Option<&str>, body: &'static str) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("token", token);
}
builder.body(Body::from(body)).unwrap()
}
#[tokio::test]
async fn require_api_token_lets_only_the_valid_token_through() {
let router = require_api_token(Router::new().route("/probe", get(|| async {})));
assert_eq!(send(router.clone(), request("GET", "/probe", None, "")).await, StatusCode::UNAUTHORIZED);
assert_eq!(send(router.clone(), request("GET", "/probe", Some("0123abcd"), "")).await, StatusCode::UNAUTHORIZED);
assert_eq!(send(router, request("GET", "/probe", Some(API_TOKEN.to_hex_text()), "")).await, StatusCode::OK);
}
#[tokio::test]
async fn every_route_requires_the_api_token() {
let mut unprotected_routes = Vec::new();
for route in registered_routes() {
// A task of its own turns a panicking handler into a finding instead of ending the test.
let answer = tokio::spawn(send(create_router(), request(&route.method, &route.path, None, "")));
match timeout(REFUSAL_LIMIT, answer).await {
Ok(Ok(StatusCode::UNAUTHORIZED)) => {},
Ok(Ok(status)) => unprotected_routes.push(format!("{} {} answered {status}", route.method, route.path)),
Ok(Err(_)) => unprotected_routes.push(format!("{} {} panicked in its handler", route.method, route.path)),
Err(_) => unprotected_routes.push(format!("{} {} did not refuse within {REFUSAL_LIMIT:?}", route.method, route.path)),
}
}
assert!(unprotected_routes.is_empty(), "These routes answer without the API token: {unprotected_routes:#?}");
}
#[tokio::test]
async fn a_valid_token_gets_through_to_the_handler() {
// The handler fails on the incomplete body, before it touches any file.
let with_token = request("POST", "/image/prepare", Some(API_TOKEN.to_hex_text()), "{}");
assert_eq!(send(create_router(), with_token).await, StatusCode::UNPROCESSABLE_ENTITY);
}
}