Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions crates/rustapi-core/src/action.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! Action registry and dispatch for server actions.
use crate::error::ApiError;
use crate::extract::{Body, Headers, Path};
use crate::response::IntoResponse;
use crate::response::Response;
use bytes::Bytes;
use http::{header, HeaderMap};
use inventory::collect;
use serde::de::DeserializeOwned;
use std::future::Future;
use std::pin::Pin;

/// The route path used for action dispatch.
pub const ACTIONS_PATH: &str = "/__actions/{action_id}";

/// A request payload passed to action handlers.
#[derive(Debug, Clone)]
pub struct ActionRequest {
pub body: Bytes,
}

/// A boxed future returned by action handlers.
pub type ActionFuture = Pin<Box<dyn Future<Output = Response> + Send>>;

/// Function pointer for action handlers.
pub type ActionHandlerFn = fn(ActionRequest) -> ActionFuture;

/// Registry entry for a server action.
pub struct ActionDefinition {
pub id: &'static str,
pub handler: ActionHandlerFn,
}

/// Convert an incoming request body into a typed input payload.
pub fn decode_action_input<T: DeserializeOwned>(body: Bytes) -> Result<T, ApiError> {
serde_json::from_slice(&body)
.map_err(|err| ApiError::bad_request(format!("Invalid action payload: {}", err)))
}

collect!(ActionDefinition);

/// Find a registered action by id.
pub fn find_action(id: &str) -> Option<&'static ActionDefinition> {
inventory::iter::<ActionDefinition>
.into_iter()
.find(|action| action.id == id)
}

/// Handle an action POST request.
pub async fn action_handler(
Path(action_id): Path<String>,
Headers(headers): Headers,
Body(body): Body,
) -> Response {
if let Err(err) = enforce_csrf(&headers) {
return err.into_response();
}

let Some(action) = find_action(&action_id) else {
return ApiError::not_found(format!("Action '{}' not found", action_id)).into_response();
Comment on lines +60 to +61
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action_id parameter from the URL path is not validated or sanitized before being used. While it's used for lookup and in an error message, the lack of validation could lead to verbose error messages or logging issues. Consider adding validation to ensure action_id contains only expected characters (e.g., alphanumeric, underscores) and has reasonable length limits.

Copilot uses AI. Check for mistakes.
};

(action.handler)(ActionRequest { body }).await
}

fn enforce_csrf(headers: &HeaderMap) -> Result<(), ApiError> {
let header_token = headers
.get("x-csrf-token")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());

let cookie_token = extract_cookie(headers, "csrf_token");

match (cookie_token, header_token) {
(Some(cookie), Some(header)) if cookie == header => Ok(()),
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no validation or documentation about what constitutes a valid CSRF token. The implementation doesn't check if the token is empty, or enforce any minimum length or format requirements. An empty string in both cookie and header would pass the check. Consider adding validation to ensure tokens meet minimum security requirements (e.g., non-empty, minimum length, possibly format validation).

Suggested change
(Some(cookie), Some(header)) if cookie == header => Ok(()),
(Some(cookie), Some(header)) if cookie == header && !cookie.is_empty() => Ok(()),

Copilot uses AI. Check for mistakes.
_ => Err(ApiError::forbidden("Invalid CSRF token")),
}
Comment on lines +76 to +78
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSRF token comparison uses simple string equality which may be vulnerable to timing attacks. An attacker could potentially use timing differences to determine the correct token byte-by-byte. Use a constant-time comparison function such as those provided by the subtle crate or implement a secure comparison that processes the full length regardless of when a mismatch is found.

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +78
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that CSRF is enforced, but there is no documentation explaining to users how to generate or set the CSRF token on the client side, or how to configure the csrf_token cookie. Without this information, developers won't know how to properly use this feature. Add documentation explaining the complete CSRF flow including token generation, cookie setting, and client-side header requirements.

Copilot uses AI. Check for mistakes.
}

fn extract_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;

cookie_header
.split(';')
.map(str::trim)
.filter(|pair| !pair.is_empty())
.find_map(|pair| {
let (key, value) = pair.split_once('=')?;
if key.trim() == name {
Some(value.trim().to_string())
} else {
None
}
})
}
Comment on lines +94 to +96
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ActionFuture type is used by the macro-generated code but is not exported in the public API. The macro at line 559 in rustapi-macros/src/lib.rs references ::rustapi_rs::ActionFuture, but this type is not re-exported through rustapi-core/src/lib.rs or rustapi-rs/src/lib.rs. This will cause compilation errors when users try to use the #[rustapi::action] macro. Add ActionFuture to the exports in both lib.rs files.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +96
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie parsing implementation is vulnerable to cookie injection attacks. The simple split-based parser does not properly handle quoted cookie values that may contain semicolons or equals signs. For example, a cookie value like 'session="a=b;c=d"' would be incorrectly parsed. Consider using the existing Cookies extractor from rustapi-core::extract which properly handles cookie parsing via the cookie crate, or ensure this implementation handles quoted values and edge cases correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +96
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action subsystem lacks any test coverage. Given that other modules in this crate (e.g., extract.rs, response.rs) include comprehensive test suites, this new module should also include tests for CSRF enforcement, action registration, action dispatch, cookie extraction, and error handling scenarios.

Copilot uses AI. Check for mistakes.
9 changes: 9 additions & 0 deletions crates/rustapi-core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BOD
use crate::response::IntoResponse;
use crate::router::{MethodRouter, Router};
use crate::server::Server;
use crate::{action_handler, ACTIONS_PATH};
use std::collections::HashMap;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};

Expand Down Expand Up @@ -390,6 +391,14 @@ impl RustApi {
self
}

/// Enable server actions dispatch endpoint.
///
/// Registers a POST handler at `/__actions/{action_id}` that dispatches to
/// action definitions registered by `#[rustapi::action]`.
pub fn actions(self) -> Self {
self.route(ACTIONS_PATH, crate::post(action_handler))
}

/// Add a typed route
pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
self.route(P::PATH, method_router)
Expand Down
6 changes: 6 additions & 0 deletions crates/rustapi-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//! - **Application Builder**: [`RustApi`] - The main entry point for building web applications
//! - **Routing**: [`Router`], [`get`], [`post`], [`put`], [`patch`], [`delete`] - HTTP routing primitives
//! - **Extractors**: [`Json`], [`Query`], [`Path`], [`State`], [`Body`], [`Headers`] - Request data extraction
//! - **Actions**: [`action_handler`], [`ActionDefinition`] - Server action registration/dispatch
//! - **Responses**: [`IntoResponse`], [`Created`], [`NoContent`], [`Html`], [`Redirect`] - Response types
//! - **Middleware**: [`BodyLimitLayer`], [`RequestIdLayer`], [`TracingLayer`] - Request processing layers
//! - **Error Handling**: [`ApiError`], [`Result`] - Structured error responses
Expand Down Expand Up @@ -49,6 +50,7 @@
//! full framework experience with all features and re-exports.

mod app;
mod action;
pub mod auto_route;
pub use auto_route::collect_auto_routes;
pub mod auto_schema;
Expand Down Expand Up @@ -83,11 +85,15 @@ pub mod __private {
pub use crate::auto_route::AUTO_ROUTES;
pub use crate::auto_schema::AUTO_SCHEMAS;
pub use linkme;
pub use inventory;
pub use rustapi_openapi;
}

// Public API
pub use app::{RustApi, RustApiConfig};
pub use action::{
action_handler, decode_action_input, find_action, ActionDefinition, ActionRequest, ACTIONS_PATH,
};
pub use error::{get_environment, ApiError, Environment, FieldError, Result};
#[cfg(feature = "cookies")]
pub use extract::Cookies;
Expand Down
96 changes: 96 additions & 0 deletions crates/rustapi-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,102 @@ pub fn delete(attr: TokenStream, item: TokenStream) -> TokenStream {
generate_route_handler("DELETE", attr, item)
}

/// Register a server action handler.
///
/// The annotated async function should accept a single input type that
/// implements `serde::Deserialize` and return a type implementing
/// `IntoResponse` (commonly `Result<Json<T>, ApiError>` or `Redirect`).
///
/// The action will be auto-registered and can be invoked via the
/// `/__actions/{action_id}` endpoint.
#[proc_macro_attribute]
pub fn action(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as ItemFn);

if input.sig.asyncness.is_none() {
return syn::Error::new_spanned(
&input.sig,
"#[rustapi::action] functions must be async",
)
.to_compile_error()
.into();
}

if !input.sig.generics.params.is_empty() {
return syn::Error::new_spanned(
&input.sig.generics,
"#[rustapi::action] functions do not support generics",
)
.to_compile_error()
.into();
}

let fn_name = &input.sig.ident;
let fn_vis = &input.vis;
let fn_attrs = &input.attrs;
let fn_inputs = &input.sig.inputs;
let fn_output = &input.sig.output;
let fn_block = &input.block;

let first_arg = match fn_inputs.first() {
Some(FnArg::Typed(pat_ty)) => pat_ty.ty.clone(),
_ => {
return syn::Error::new_spanned(
&input.sig.inputs,
"#[rustapi::action] requires a single typed argument",
)
.to_compile_error()
.into();
}
};

if fn_inputs.len() != 1 {
return syn::Error::new_spanned(
&input.sig.inputs,
"#[rustapi::action] requires exactly one argument",
)
.to_compile_error()
.into();
}
Comment on lines +527 to +546
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic checks if there's at least one argument before checking if there's exactly one argument. The first check at lines 527-536 will succeed if there's one or more arguments, then the second check at lines 539-546 verifies exactly one. This means if a user provides zero arguments, they get the 'requires a single typed argument' error instead of the more accurate 'requires exactly one argument' error. Consider reordering these checks or combining them for clearer error messages.

Copilot uses AI. Check for mistakes.

let action_id = fn_name.to_string();
let registrar_name = syn::Ident::new(&format!("__RUSTAPI_ACTION_{}", fn_name), fn_name.span());
let wrapper_name = syn::Ident::new(&format!("__rustapi_action_wrapper_{}", fn_name), fn_name.span());
Comment on lines +549 to +550
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The macro generates identifiers using simple concatenation with the function name, which could lead to naming collisions if a user has functions with names like 'submit' and '__RUSTAPI_ACTION_submit' in the same scope. While unlikely, using a more unique prefix or incorporating a hash of the span would make collisions virtually impossible.

Copilot uses AI. Check for mistakes.

let expanded = quote! {
#(#fn_attrs)*
#fn_vis async fn #fn_name(#fn_inputs) #fn_output #fn_block

#[doc(hidden)]
#fn_vis fn #wrapper_name(
req: ::rustapi_rs::ActionRequest,
) -> ::rustapi_rs::ActionFuture {
Box::pin(async move {
let input: #first_arg = match ::rustapi_rs::decode_action_input(req.body) {
Ok(value) => value,
Err(err) => {
return err.into_response();
}
};
let result = #fn_name(input).await;
::rustapi_rs::IntoResponse::into_response(result)
})
}

#[allow(non_upper_case_globals)]
static #registrar_name: ::rustapi_rs::ActionDefinition = ::rustapi_rs::ActionDefinition {
id: #action_id,
handler: #wrapper_name,
};

::rustapi_rs::__private::inventory::submit! { #registrar_name }
};

debug_output(&format!("action {}", action_id), &expanded);

TokenStream::from(expanded)
}

// ============================================
// Route Metadata Macros
// ============================================
Expand Down
4 changes: 4 additions & 0 deletions crates/rustapi-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ pub mod prelude {
Body,
ClientIp,
Created,
ActionDefinition,
ActionRequest,
ACTIONS_PATH,
decode_action_input,
Extension,
HeaderValue,
Headers,
Expand Down
Loading