Skip to content

Conversation

@Tuntii
Copy link
Owner

@Tuntii Tuntii commented Jan 19, 2026

Motivation

  • Provide a way to register server-side actions that accept typed inputs and can be invoked from the client via a single POST endpoint.
  • Ensure safe invocation by deserializing into typed payloads and enforcing CSRF via a double-submit cookie check.
  • Offer a convenient builder hook to wire the actions endpoint into applications (RustApi::actions()).

Description

  • Added a new action subsystem in crates/rustapi-core/src/action.rs including ActionDefinition, ActionRequest, ActionFuture, find_action, decode_action_input, and action_handler which dispatches actions at the route "/__actions/{action_id}" and enforces CSRF by comparing the x-csrf-token header to a csrf_token cookie.
  • Exposed the action API in the core crate and prelude via pub use and exported inventory into the macro internals so actions can be registered at link time.
  • Added RustApi::actions() in crates/rustapi-core/src/app.rs which mounts the POST handler at ACTIONS_PATH using the existing route helpers.
  • Implemented a new proc-macro #[rustapi::action] in crates/rustapi-macros/src/lib.rs that requires an async function with exactly one typed argument, auto-generates a wrapper which uses decode_action_input to deserialize JSON input, registers the action via inventory, and returns the handler result as a typed response.
  • Re-exported action helpers (ActionDefinition, ActionRequest, ACTIONS_PATH, decode_action_input) in crates/rustapi-rs/src/lib.rs prelude for convenient use in applications.

Testing

  • No automated tests were executed as part of this change (no cargo test or CI run was invoked).
  • The change includes new code paths and macro expansions that should be validated by compiling and running the existing test suite or integration scenarios before release.

Codex Task

Copilot AI review requested due to automatic review settings January 19, 2026 22:46
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a server action registry system with automatic route registration, CSRF protection, and a procedural macro for defining type-safe action handlers. The implementation enables developers to define server-side actions that can be invoked from clients via a single POST endpoint with built-in security.

Changes:

  • Introduced an action subsystem with registration via inventory, dispatch handler, and CSRF validation using double-submit cookie pattern
  • Added #[rustapi::action] procedural macro to auto-generate action wrappers and register handlers
  • Exposed new builder method RustApi::actions() to mount the actions endpoint

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
crates/rustapi-core/src/action.rs New action registry module with CSRF enforcement, action dispatch handler, and cookie extraction utilities
crates/rustapi-macros/src/lib.rs New #[rustapi::action] proc macro with validation and code generation for action handlers
crates/rustapi-core/src/app.rs Added actions() builder method to register the actions POST endpoint
crates/rustapi-core/src/lib.rs Exported action types and added inventory to private macro internals
crates/rustapi-rs/src/lib.rs Re-exported action-related types in the prelude for user convenience

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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
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
}
})
}
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 +76 to +78
(Some(cookie), Some(header)) if cookie == header => Ok(()),
_ => Err(ApiError::forbidden("Invalid CSRF token")),
}
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 +1 to +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();
};

(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(()),
_ => Err(ApiError::forbidden("Invalid CSRF token")),
}
}

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
}
})
}
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.
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.
Comment on lines +60 to +61
let Some(action) = find_action(&action_id) else {
return ApiError::not_found(format!("Action '{}' not found", action_id)).into_response();
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.
Comment on lines +549 to +550
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());
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.
Comment on lines +527 to +546
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();
}
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.
Comment on lines +67 to +78
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(()),
_ => Err(ApiError::forbidden("Invalid CSRF token")),
}
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.
@Tuntii Tuntii closed this Jan 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants