-
Notifications
You must be signed in to change notification settings - Fork 1
Add server action registry, #[rustapi::action] macro, and /__actions dispatch with CSRF #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this 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.
| } | ||
| }) | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| 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 | ||
| } | ||
| }) | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| (Some(cookie), Some(header)) if cookie == header => Ok(()), | ||
| _ => Err(ApiError::forbidden("Invalid CSRF token")), | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| //! 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 | ||
| } | ||
| }) | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| let cookie_token = extract_cookie(headers, "csrf_token"); | ||
|
|
||
| match (cookie_token, header_token) { | ||
| (Some(cookie), Some(header)) if cookie == header => Ok(()), |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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).
| (Some(cookie), Some(header)) if cookie == header => Ok(()), | |
| (Some(cookie), Some(header)) if cookie == header && !cookie.is_empty() => Ok(()), |
| let Some(action) = find_action(&action_id) else { | ||
| return ApiError::not_found(format!("Action '{}' not found", action_id)).into_response(); |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| 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()); |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| 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(); | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
| 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")), | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
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.
Motivation
RustApi::actions()).Description
crates/rustapi-core/src/action.rsincludingActionDefinition,ActionRequest,ActionFuture,find_action,decode_action_input, andaction_handlerwhich dispatches actions at the route"/__actions/{action_id}"and enforces CSRF by comparing thex-csrf-tokenheader to acsrf_tokencookie.pub useand exportedinventoryinto the macro internals so actions can be registered at link time.RustApi::actions()incrates/rustapi-core/src/app.rswhich mounts the POST handler atACTIONS_PATHusing the existing route helpers.#[rustapi::action]incrates/rustapi-macros/src/lib.rsthat requires anasyncfunction with exactly one typed argument, auto-generates a wrapper which usesdecode_action_inputto deserialize JSON input, registers the action viainventory, and returns the handler result as a typed response.ActionDefinition,ActionRequest,ACTIONS_PATH,decode_action_input) incrates/rustapi-rs/src/lib.rsprelude for convenient use in applications.Testing
cargo testor CI run was invoked).Codex Task