Skip to main content

max / makenotwork

server: replace global CSRF allowlist with per-route posture helpers Mutation routes now opt into one of {post,put,patch,delete}_csrf, *_csrf_manual, or *_csrf_skip; CsrfRouter wraps axum's Router so a bare post(handler) fails to compile inside any of the 14 mutation-bearing route files. The exempt_prefixes allowlist and csrf_middleware are gone. Manual posture is used only by the tip handler; routed through extract_token_from_request so header-then-form precedence matches the old global middleware. Skip reasons (STRIPE_SESSION_SKIP, SYNCKIT_API_KEY_SKIP, etc.) live at the call site for grep. Closes Phase 5 chronic remediation (Landing 2). Phase 1 follow-ups for /login, creator-tier, and cancel_pending_item_checkout still open.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-27 05:14 UTC
Commit: a8f98805c685ceb1272082050f72840f0ab6ac5b
Parent: 78dda3d
20 files changed, +989 insertions, -621 deletions
M server/src/csrf.rs +304 -43
@@ -7,9 +7,12 @@
7 7
8 8 use axum::{
9 9 extract::{FromRequestParts, Request},
10 + handler::Handler,
10 11 http::{header::HeaderMap, request::Parts, StatusCode},
11 - middleware::Next,
12 + middleware::{from_fn, Next},
12 13 response::{IntoResponse, Response},
14 + routing::{delete, patch, post, put, MethodRouter},
15 + Router,
13 16 };
14 17 use rand::RngCore;
15 18 use tower_sessions::Session;
@@ -129,50 +132,292 @@ where
129 132 }
130 133 }
131 134
132 - /// Middleware to validate CSRF tokens on state-changing requests
133 - ///
134 - /// Validates POST, PUT, PATCH, DELETE requests (except for excluded paths).
135 - /// Checks the `X-CSRF-Token` header first (used by HTMX), then falls back to
136 - /// parsing the `_csrf` field from form-encoded request bodies (used by vanilla
137 - /// HTML forms).
138 - pub async fn csrf_middleware(request: Request, next: Next) -> Response {
139 - let method = request.method().clone();
140 -
141 - // Only validate state-changing methods
142 - if !["POST", "PUT", "PATCH", "DELETE"].contains(&method.as_str()) {
143 - return next.run(request).await;
135 + /// Per-route CSRF posture, declared at the route registration site via the
136 + /// `{post,put,patch,delete}_csrf*` helpers. Carried in the helper signatures
137 + /// so the choice (and its reason) lives next to the route, not in a sibling
138 + /// allowlist file. Not stored at runtime — the reason strings exist for
139 + /// source-level documentation and grep, while the structural guarantee comes
140 + /// from `CsrfRouter` only accepting `PostureMethodRouter` values.
141 + #[derive(Clone, Copy, Debug)]
142 + pub enum CsrfPosture {
143 + /// Standard validation layer runs (header or form `_csrf`).
144 + Auto,
145 + /// Handler validates the token itself and proves it with the
146 + /// `CsrfManuallyValidated` witness. Reason documents why the
147 + /// standard layer can't apply (e.g. "multipart upload").
148 + Manual(&'static str),
149 + /// No CSRF check applies. Reason documents why (webhook signature,
150 + /// signed link, pre-auth, etc.).
151 + Skip(&'static str),
152 + }
153 +
154 + /// Witness type proving a handler ran the standard CSRF validation path.
155 + /// The only public way to obtain one is `validate_token_consuming`, which
156 + /// performs the check. The private field with a private-module constructor
157 + /// makes the value un-fabricable from outside this module — `Default`,
158 + /// struct-literal, and `Clone` are all impossible for callers.
159 + pub use sealed::CsrfManuallyValidated;
160 +
161 + mod sealed {
162 + pub struct CsrfManuallyValidated {
163 + _private: (),
144 164 }
145 165
146 - let path = request.uri().path().to_string();
147 -
148 - // Exempt paths:
149 - // - Webhooks use their own signature verification
150 - // - Auth endpoints establish sessions (pre-auth, no CSRF needed)
151 - // - Stripe checkout is a vanilla form POST that redirects to Stripe's hosted page;
152 - // SameSite=Lax cookies prevent cross-site form submissions, AuthUser is required,
153 - // and no state mutation occurs until Stripe's webhook confirms payment
154 - // - /confirm-delete uses a signed HMAC link as its authorization; the user
155 - // arrives from an email and may not have an active session, so the
156 - // standard CSRF header cannot be attached to the vanilla form POST.
157 - // Exempt path prefixes: a path matches if it equals the prefix exactly
158 - // or continues with '/'. This prevents "/loginX" from matching "/login".
159 - let exempt_prefixes = [
160 - "/stripe/webhook", "/stripe/checkout", "/stripe/subscribe",
161 - "/login", "/join",
162 - "/api/sync/auth", "/api/sync/push", "/api/sync/pull", "/api/sync/status",
163 - "/api/sync/devices", "/api/sync/keys", "/api/sync/blobs",
164 - "/oauth", "/auth/passkey", "/postmark",
165 - "/unsubscribe", "/confirm-delete",
166 - "/api/checkout/guest", "/api/checkout/guest-free",
167 - ];
168 -
169 - let is_exempt = exempt_prefixes.iter().any(|p| {
170 - path == *p || path.starts_with(&format!("{p}/"))
171 - });
172 - if is_exempt {
173 - return next.run(request).await;
166 + pub(super) fn make_validated() -> CsrfManuallyValidated {
167 + CsrfManuallyValidated { _private: () }
168 + }
169 + }
170 +
171 + /// Validate a token and return a sealed witness on success. Used by
172 + /// handlers registered with `post_csrf_manual` (and method variants)
173 + /// that need to validate inside the handler body — typically because the
174 + /// global middleware can't read the token for this content type (e.g.
175 + /// multipart) or because validation is conditional on request state.
176 + pub async fn validate_token_consuming(
177 + session: &Session,
178 + provided_token: &str,
179 + ) -> Result<CsrfManuallyValidated, AppError> {
180 + if validate_token(session, provided_token).await? {
181 + Ok(sealed::make_validated())
182 + } else {
183 + Err(AppError::Forbidden)
184 + }
185 + }
186 +
187 + /// Wrap a method-router with the Auto-posture validation layer.
188 + /// Runs `validate_auto` on every request that reaches the route.
189 + fn attach_auto_layer<S>(method_router: MethodRouter<S>) -> MethodRouter<S>
190 + where
191 + S: Clone + Send + Sync + 'static,
192 + {
193 + method_router.layer(from_fn(|req: Request, next: Next| async move {
194 + let path = req.uri().path().to_string();
195 + validate_auto(req, next, &path).await
196 + }))
197 + }
198 +
199 + /// A `MethodRouter` that has been through one of the CSRF helpers. Field
200 + /// is private and constructible only inside this module, so
201 + /// `CsrfRouter::route` will not accept a bare `axum::routing::post(handler)`
202 + /// — route files have to use the helpers, by construction.
203 + pub use posture_router::PostureMethodRouter;
204 +
205 + mod posture_router {
206 + use super::*;
207 +
208 + pub struct PostureMethodRouter<S = ()>(pub(super) MethodRouter<S>);
209 +
210 + impl<S> PostureMethodRouter<S>
211 + where
212 + S: Clone + Send + Sync + 'static,
213 + {
214 + pub(super) fn new(inner: MethodRouter<S>) -> Self {
215 + Self(inner)
216 + }
217 +
218 + pub(super) fn into_inner(self) -> MethodRouter<S> {
219 + self.0
220 + }
221 +
222 + /// Attach an additional tower layer (e.g. a rate limiter) to the
223 + /// underlying method router. Returns `Self` so callers don't lose
224 + /// the posture stamp.
225 + pub fn layer<L>(self, layer: L) -> Self
226 + where
227 + L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
228 + L::Service:
229 + tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
230 + <L::Service as tower::Service<axum::extract::Request>>::Response:
231 + axum::response::IntoResponse + 'static,
232 + <L::Service as tower::Service<axum::extract::Request>>::Error:
233 + Into<std::convert::Infallible> + 'static,
234 + <L::Service as tower::Service<axum::extract::Request>>::Future:
235 + Send + 'static,
236 + {
237 + Self(self.0.layer(layer))
238 + }
239 + }
240 + }
241 +
242 + macro_rules! csrf_auto_helper {
243 + ($name:ident, $axum_fn:ident) => {
244 + pub fn $name<H, T, S>(handler: H) -> PostureMethodRouter<S>
245 + where
246 + H: Handler<T, S>,
247 + T: 'static,
248 + S: Clone + Send + Sync + 'static,
249 + {
250 + posture_router::PostureMethodRouter::new(attach_auto_layer($axum_fn(handler)))
251 + }
252 + };
253 + }
254 +
255 + macro_rules! csrf_passthrough_helper {
256 + ($name:ident, $axum_fn:ident, $variant:ident) => {
257 + pub fn $name<H, T, S>(reason: &'static str, handler: H) -> PostureMethodRouter<S>
258 + where
259 + H: Handler<T, S>,
260 + T: 'static,
261 + S: Clone + Send + Sync + 'static,
262 + {
263 + let _ = CsrfPosture::$variant(reason);
264 + posture_router::PostureMethodRouter::new($axum_fn(handler))
265 + }
266 + };
267 + }
268 +
269 + // Auto posture: standard CSRF validation (header or form `_csrf`).
270 + csrf_auto_helper!(post_csrf, post);
271 + csrf_auto_helper!(put_csrf, put);
272 + csrf_auto_helper!(patch_csrf, patch);
273 + csrf_auto_helper!(delete_csrf, delete);
274 +
275 + // Manual posture: handler validates via `validate_token_consuming`.
276 + csrf_passthrough_helper!(post_csrf_manual, post, Manual);
277 + csrf_passthrough_helper!(put_csrf_manual, put, Manual);
278 + csrf_passthrough_helper!(patch_csrf_manual, patch, Manual);
279 + csrf_passthrough_helper!(delete_csrf_manual, delete, Manual);
280 +
281 + // Skip posture: no CSRF check. Reason documents why.
282 + csrf_passthrough_helper!(post_csrf_skip, post, Skip);
283 + csrf_passthrough_helper!(put_csrf_skip, put, Skip);
284 + csrf_passthrough_helper!(patch_csrf_skip, patch, Skip);
285 + csrf_passthrough_helper!(delete_csrf_skip, delete, Skip);
286 +
287 + // --- Wrappers for multi-method routes ------------------------------------
288 + //
289 + // A handful of routes register multiple HTTP methods on one path
290 + // (e.g. `get(list).post(create)`). The handler-taking helpers above can't
291 + // compose with these because the chain is already a `MethodRouter`. These
292 + // wrappers take a pre-built `MethodRouter` and stamp it as a
293 + // `PostureMethodRouter`. Read methods (GET/HEAD) are unaffected — the
294 + // Auto validation layer only intercepts state-changing methods at the
295 + // per-route level because that's what the helper attached to.
296 +
297 + /// Wrap a multi-method chain with the Auto-posture validation layer.
298 + pub fn with_csrf<S>(method_router: MethodRouter<S>) -> PostureMethodRouter<S>
299 + where
300 + S: Clone + Send + Sync + 'static,
301 + {
302 + posture_router::PostureMethodRouter::new(attach_auto_layer(method_router))
303 + }
304 +
305 + /// Stamp a multi-method chain as Manual — handler is responsible for
306 + /// calling `validate_token_consuming`.
307 + pub fn with_csrf_manual<S>(
308 + reason: &'static str,
309 + method_router: MethodRouter<S>,
310 + ) -> PostureMethodRouter<S>
311 + where
312 + S: Clone + Send + Sync + 'static,
313 + {
314 + let _ = CsrfPosture::Manual(reason);
315 + posture_router::PostureMethodRouter::new(method_router)
316 + }
317 +
318 + /// Stamp a multi-method chain as Skip — no CSRF check applies.
319 + pub fn with_csrf_skip<S>(
320 + reason: &'static str,
321 + method_router: MethodRouter<S>,
322 + ) -> PostureMethodRouter<S>
323 + where
324 + S: Clone + Send + Sync + 'static,
325 + {
326 + let _ = CsrfPosture::Skip(reason);
327 + posture_router::PostureMethodRouter::new(method_router)
328 + }
329 +
330 + // --- CsrfRouter: structural enforcement ----------------------------------
331 + //
332 + // `CsrfRouter` is the only way to register a mutation route in this
333 + // codebase. Its `route` method takes a `PostureMethodRouter<S>`, whose
334 + // constructor is private to this module, so the only producers are the
335 + // helpers above. A bare `Router::route(path, post(handler))` cannot
336 + // reach a mounted `CsrfRouter` without going through `finalize()` first,
337 + // which is only called once in `build_app`.
338 +
339 + pub struct CsrfRouter<S = ()>(Router<S>);
340 +
341 + impl<S> Default for CsrfRouter<S>
342 + where
343 + S: Clone + Send + Sync + 'static,
344 + {
345 + fn default() -> Self {
346 + Self::new()
347 + }
348 + }
349 +
350 + impl<S> CsrfRouter<S>
351 + where
352 + S: Clone + Send + Sync + 'static,
353 + {
354 + pub fn new() -> Self {
355 + Self(Router::new())
356 + }
357 +
358 + pub fn route(self, path: &str, posture: PostureMethodRouter<S>) -> Self {
359 + Self(self.0.route(path, posture.into_inner()))
360 + }
361 +
362 + /// Register a read-only route (GET / HEAD / OPTIONS). The structural
363 + /// guarantee only constrains state-changing methods, so read-only
364 + /// `MethodRouter`s pass through unchanged. Calling this with a
365 + /// `MethodRouter` that includes POST/PUT/PATCH/DELETE compiles, but
366 + /// readers can see the intent at the call site — and any mutation
367 + /// route registered through `route_get` is a bug visible in review.
368 + pub fn route_get(self, path: &str, method_router: MethodRouter<S>) -> Self {
369 + Self(self.0.route(path, method_router))
174 370 }
175 371
372 + pub fn merge(self, other: Self) -> Self {
373 + Self(self.0.merge(other.0))
374 + }
375 +
376 + pub fn nest(self, path: &str, other: Self) -> Self {
377 + Self(self.0.nest(path, other.0))
378 + }
379 +
380 + pub fn layer<L>(self, layer: L) -> Self
381 + where
382 + L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
383 + L::Service:
384 + tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
385 + <L::Service as tower::Service<axum::extract::Request>>::Response:
386 + IntoResponse + 'static,
387 + <L::Service as tower::Service<axum::extract::Request>>::Error:
388 + Into<std::convert::Infallible> + 'static,
389 + <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
390 + {
391 + Self(self.0.layer(layer))
392 + }
393 +
394 + pub fn route_layer<L>(self, layer: L) -> Self
395 + where
396 + L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
397 + L::Service:
398 + tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
399 + <L::Service as tower::Service<axum::extract::Request>>::Response:
400 + IntoResponse + 'static,
401 + <L::Service as tower::Service<axum::extract::Request>>::Error:
402 + Into<std::convert::Infallible> + 'static,
403 + <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
404 + {
405 + Self(self.0.route_layer(layer))
406 + }
407 +
408 + /// Drop the structural envelope and return the underlying `Router<S>`.
409 + /// Called once in `build_app` after all mutation routes have been
410 + /// registered; downstream code may then attach global layers, mount
411 + /// static-file services, and add GET-only routes.
412 + pub fn finalize(self) -> Router<S> {
413 + self.0
414 + }
415 + }
416 +
417 + /// Standard CSRF validation: header `X-CSRF-Token` first, then form-body
418 + /// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes
419 + /// and by the path-allowlist fallback during the L2 migration.
420 + async fn validate_auto(request: Request, next: Next, path: &str) -> Response {
176 421 // Get session from extensions
177 422 let session = match request.extensions().get::<Session>() {
178 423 Some(s) => s.clone(),
@@ -225,8 +470,9 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response {
225 470 // forms (uploads go through HTMX + fetch, which attach
226 471 // `X-CSRF-Token` on the header path above), so rejecting here is
227 472 // the explicit boundary. If multipart adoption ever becomes
228 - // necessary, add a content-type branch that streams the body
229 - // through a multipart parser instead of naive `to_bytes`.
473 + // necessary, register the route with `post_csrf_manual` and have
474 + // the handler stream the body through a multipart parser before
475 + // calling `validate_token_consuming`.
230 476 // - `application/json` and others must use the `X-CSRF-Token`
231 477 // header — anything that can set a custom header can set this one.
232 478 let content_type = request
@@ -439,6 +685,21 @@ mod tests {
439 685 }
440 686
441 687 #[test]
688 + fn csrf_manually_validated_marker_is_zero_sized() {
689 + assert_eq!(std::mem::size_of::<CsrfManuallyValidated>(), 0);
690 + }
691 +
692 + #[test]
693 + fn csrf_posture_is_copyable_and_carries_reason() {
694 + let p = CsrfPosture::Skip("webhook: stripe signature");
695 + let copy = p;
696 + match copy {
697 + CsrfPosture::Skip(r) => assert_eq!(r, "webhook: stripe signature"),
698 + _ => panic!("variant mismatch"),
699 + }
700 + }
701 +
702 + #[test]
442 703 fn test_constant_time_compare_truncated() {
443 704 use crate::helpers::constant_time_compare;
444 705 let token = generate_token();
@@ -127,8 +127,12 @@ pub fn build_app(
127 127 session_layer: SessionManagerLayer<PostgresStore>,
128 128 ) -> Router {
129 129 let metrics_handle = state.metrics_handle.clone();
130 - let mut app = Router::new()
131 - .merge(page_routes())
130 + // All mutation-bearing sub-routers register through `CsrfRouter`, whose
131 + // `route` method only accepts `PostureMethodRouter` values produced by
132 + // the `csrf::*_csrf*` helpers. Finalising the merged tree drops the
133 + // structural envelope so global middleware, static-file mounts, and
134 + // the few bare GETs below can attach to a plain `Router<AppState>`.
135 + let csrf_routes = csrf::CsrfRouter::new()
132 136 .merge(auth_routes())
133 137 .merge(api_routes())
134 138 .merge(storage_routes())
@@ -137,10 +141,14 @@ pub fn build_app(
137 141 .merge(synckit_routes())
138 142 .merge(oauth_routes())
139 143 .merge(postmark_routes())
140 - .merge(git_routes())
141 144 .merge(git_issue_routes())
142 145 .merge(ota_routes())
143 146 .merge(build_routes())
147 + .finalize();
148 + let mut app = Router::new()
149 + .merge(page_routes())
150 + .merge(csrf_routes)
151 + .merge(git_routes())
144 152 .merge(routes::embed::embed_routes())
145 153 .route("/api/openapi.json", axum::routing::get(openapi::openapi_json))
146 154 .route("/robots.txt", axum::routing::get(|| async {
@@ -204,7 +212,6 @@ pub fn build_app(
204 212 app.layer(middleware::from_fn_with_state(state.clone(), security_headers_middleware))
205 213 .layer(middleware::from_fn(metrics::cache_control_middleware))
206 214 .layer(middleware::from_fn(metrics::metrics_middleware))
207 - .layer(middleware::from_fn(csrf::csrf_middleware))
208 215 .layer(middleware::from_fn_with_state(state.clone(), metrics::idempotency_middleware))
209 216 .layer(session_layer)
210 217 .layer(RequestBodyLimitLayer::new(1024 * 1024))
@@ -9,71 +9,72 @@ mod waitlist;
9 9 use axum::{
10 10 extract::{Path, State},
11 11 response::{IntoResponse, Response},
12 - routing::{get, post},
13 - Form, Router,
12 + routing::get,
13 + Form,
14 14 };
15 15 use serde::Deserialize;
16 16
17 17 use crate::{
18 18 auth::AdminUser,
19 + csrf::{post_csrf, CsrfRouter},
19 20 db::{self, UserId},
20 21 error::{AppError, Result},
21 22 AppState,
22 23 };
23 24
24 25 /// Register admin routes for waitlist management, user moderation, and lottery.
25 - pub fn admin_routes() -> Router<AppState> {
26 - Router::new()
26 + pub fn admin_routes() -> CsrfRouter<AppState> {
27 + CsrfRouter::new()
27 28 // Waitlist
28 - .route("/admin/waitlist", get(waitlist::admin_waitlist))
29 - .route("/admin/waitlist/entries", get(waitlist::admin_waitlist_entries))
30 - .route("/api/admin/waitlist/{id}/approve", post(waitlist::admin_approve))
31 - .route("/api/admin/waitlist/{id}/spam", post(waitlist::admin_spam))
32 - .route("/api/admin/lottery", post(waitlist::admin_lottery))
29 + .route_get("/admin/waitlist", get(waitlist::admin_waitlist))
30 + .route_get("/admin/waitlist/entries", get(waitlist::admin_waitlist_entries))
31 + .route("/api/admin/waitlist/{id}/approve", post_csrf(waitlist::admin_approve))
32 + .route("/api/admin/waitlist/{id}/spam", post_csrf(waitlist::admin_spam))
33 + .route("/api/admin/lottery", post_csrf(waitlist::admin_lottery))
33 34 // User management
34 - .route("/admin/users", get(users::admin_users))
35 - .route("/admin/users/entries", get(users::admin_user_entries))
36 - .route("/api/admin/users/{id}/warn", post(users::admin_warn_user))
37 - .route("/api/admin/users/{id}/suspend", post(users::admin_suspend_user))
38 - .route("/api/admin/users/{id}/unsuspend", post(users::admin_unsuspend_user))
39 - .route("/api/admin/users/{id}/terminate", post(users::admin_terminate_user))
35 + .route_get("/admin/users", get(users::admin_users))
36 + .route_get("/admin/users/entries", get(users::admin_user_entries))
37 + .route("/api/admin/users/{id}/warn", post_csrf(users::admin_warn_user))
38 + .route("/api/admin/users/{id}/suspend", post_csrf(users::admin_suspend_user))
39 + .route("/api/admin/users/{id}/unsuspend", post_csrf(users::admin_unsuspend_user))
40 + .route("/api/admin/users/{id}/terminate", post_csrf(users::admin_terminate_user))
40 41 // Upload review queue
41 - .route("/admin/uploads", get(uploads::admin_uploads))
42 - .route("/api/admin/uploads/items/{id}/promote", post(uploads::admin_promote_item))
43 - .route("/api/admin/uploads/items/{id}/quarantine", post(uploads::admin_quarantine_item))
44 - .route("/api/admin/uploads/items/{id}/rescan", post(uploads::admin_rescan_item))
45 - .route("/api/admin/uploads/versions/{id}/promote", post(uploads::admin_promote_version))
46 - .route("/api/admin/uploads/versions/{id}/quarantine", post(uploads::admin_quarantine_version))
47 - .route("/api/admin/uploads/versions/{id}/rescan", post(uploads::admin_rescan_version))
48 - .route("/api/admin/uploads/bulk/rescan", post(uploads::admin_bulk_rescan_held))
49 - .route("/api/admin/uploads/bulk/promote", post(uploads::admin_bulk_promote_held))
50 - .route("/admin/uploads/queue-summary", get(uploads::admin_queue_summary_partial))
51 - .route("/admin/uploads/audit", get(uploads::admin_scan_audit))
52 - .route("/admin/uploads/health.json", get(uploads::scan_health_json))
53 - .route("/api/admin/users/{id}/trust", post(users::admin_trust_user))
54 - .route("/api/admin/users/{id}/untrust", post(users::admin_untrust_user))
42 + .route_get("/admin/uploads", get(uploads::admin_uploads))
43 + .route("/api/admin/uploads/items/{id}/promote", post_csrf(uploads::admin_promote_item))
44 + .route("/api/admin/uploads/items/{id}/quarantine", post_csrf(uploads::admin_quarantine_item))
45 + .route("/api/admin/uploads/items/{id}/rescan", post_csrf(uploads::admin_rescan_item))
46 + .route("/api/admin/uploads/versions/{id}/promote", post_csrf(uploads::admin_promote_version))
47 + .route("/api/admin/uploads/versions/{id}/quarantine", post_csrf(uploads::admin_quarantine_version))
48 + .route("/api/admin/uploads/versions/{id}/rescan", post_csrf(uploads::admin_rescan_version))
49 + .route("/api/admin/uploads/bulk/rescan", post_csrf(uploads::admin_bulk_rescan_held))
50 + .route("/api/admin/uploads/bulk/promote", post_csrf(uploads::admin_bulk_promote_held))
51 + .route_get("/admin/uploads/queue-summary", get(uploads::admin_queue_summary_partial))
52 + .route_get("/admin/uploads/audit", get(uploads::admin_scan_audit))
53 + .route_get("/admin/uploads/health.json", get(uploads::scan_health_json))
54 + .route("/api/admin/users/{id}/trust", post_csrf(users::admin_trust_user))
55 + .route("/api/admin/users/{id}/untrust", post_csrf(users::admin_untrust_user))
55 56 // Appeals
56 - .route("/admin/appeals", get(moderation::admin_appeals))
57 - .route("/api/admin/appeals/{user_id}/decide", post(moderation::admin_decide_appeal))
57 + .route_get("/admin/appeals", get(moderation::admin_appeals))
58 + .route("/api/admin/appeals/{user_id}/decide", post_csrf(moderation::admin_decide_appeal))
58 59 // Email signups
59 - .route("/admin/signups", get(signups::admin_signups))
60 + .route_get("/admin/signups", get(signups::admin_signups))
60 61 // Reports
61 - .route("/admin/reports", get(moderation::admin_reports))
62 - .route("/admin/reports/entries", get(moderation::admin_report_entries))
63 - .route("/api/admin/reports/{id}/resolve", post(moderation::admin_resolve_report))
62 + .route_get("/admin/reports", get(moderation::admin_reports))
63 + .route_get("/admin/reports/entries", get(moderation::admin_report_entries))
64 + .route("/api/admin/reports/{id}/resolve", post_csrf(moderation::admin_resolve_report))
64 65 // Per-item content removal
65 - .route("/api/admin/items/{id}/remove", post(moderation::admin_remove_item))
66 - .route("/api/admin/items/{id}/restore", post(moderation::admin_restore_item))
66 + .route("/api/admin/items/{id}/remove", post_csrf(moderation::admin_remove_item))
67 + .route("/api/admin/items/{id}/restore", post_csrf(moderation::admin_restore_item))
67 68 // MT provisioning
68 - .route("/api/admin/mt/provision", post(admin_mt_provision))
69 + .route("/api/admin/mt/provision", post_csrf(admin_mt_provision))
69 70 // Storage overrides
70 - .route("/api/admin/users/{id}/file-override", post(admin_file_override))
71 + .route("/api/admin/users/{id}/file-override", post_csrf(admin_file_override))
71 72 // Shutdown
72 - .route("/api/admin/shutdown-notice", post(admin_shutdown_notice))
73 + .route("/api/admin/shutdown-notice", post_csrf(admin_shutdown_notice))
73 74 // Founder pricing
74 - .route("/api/admin/founder-window/close", post(admin_close_founder_window))
75 + .route("/api/admin/founder-window/close", post_csrf(admin_close_founder_window))
75 76 // Metrics
76 - .route("/admin/metrics", get(admin_metrics))
77 + .route_get("/admin/metrics", get(admin_metrics))
77 78 }
78 79
79 80 // ── MT Provisioning ──
@@ -12,62 +12,67 @@ mod uploads;
12 12
13 13 pub(super) use git::restart_status;
14 14
15 - use axum::{
16 - routing::{delete, get, post, put},
17 - Router,
15 + use axum::routing::get;
16 +
17 + use crate::{
18 + csrf::{delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip, CsrfRouter},
19 + AppState,
18 20 };
19 21
20 - use crate::AppState;
22 + /// All routes in this file are HMAC-bearer authed via `ServiceAuth` (no
23 + /// session); CSRF is not applicable. Each registration carries the same
24 + /// `Skip` reason so the posture is visible at the call site.
25 + const INTERNAL_SKIP: &str = "internal API: HMAC bearer auth, no session";
21 26
22 27 /// Internal service-to-service routes (ServiceAuth, no rate limit).
23 - pub(super) fn internal_routes() -> Router<AppState> {
24 - Router::new()
25 - .route("/api/internal/ssh-key-lookup", get(git::ssh_key_lookup))
26 - .route("/api/internal/creator/projects", get(creators::creator_projects).post(cli_features::create_project))
27 - .route("/api/internal/creator/projects/{id}/items", get(creators::creator_project_items))
28 - .route("/api/internal/creator/stats", get(creators::creator_stats))
29 - .route("/api/internal/creator/items", post(items::create_item))
30 - .route("/api/internal/upload/presign", post(uploads::presign_upload))
31 - .route("/api/internal/upload/confirm", post(uploads::confirm_upload))
32 - .route("/api/internal/creator/storage", get(uploads::creator_storage))
33 - .route("/api/internal/creator/items/{id}", get(items::get_item))
34 - .route("/api/internal/creator/items/{id}", put(items::update_item))
35 - .route("/api/internal/creator/items/{id}", delete(items::delete_item))
36 - .route("/api/internal/creator/items/{id}/publish", post(items::publish_item))
37 - .route("/api/internal/creator/items/{id}/unpublish", post(items::unpublish_item))
38 - .route("/api/internal/creator/items/{id}/versions", get(items::item_versions))
28 + pub(super) fn internal_routes() -> CsrfRouter<AppState> {
29 + CsrfRouter::new()
30 + .route_get("/api/internal/ssh-key-lookup", get(git::ssh_key_lookup))
31 + .route("/api/internal/creator/projects", with_csrf_skip(INTERNAL_SKIP, get(creators::creator_projects).post(cli_features::create_project)))
32 + .route_get("/api/internal/creator/projects/{id}/items", get(creators::creator_project_items))
33 + .route_get("/api/internal/creator/stats", get(creators::creator_stats))
34 + .route("/api/internal/creator/items", post_csrf_skip(INTERNAL_SKIP, items::create_item))
35 + .route("/api/internal/upload/presign", post_csrf_skip(INTERNAL_SKIP, uploads::presign_upload))
36 + .route("/api/internal/upload/confirm", post_csrf_skip(INTERNAL_SKIP, uploads::confirm_upload))
37 + .route_get("/api/internal/creator/storage", get(uploads::creator_storage))
38 + .route_get("/api/internal/creator/items/{id}", get(items::get_item))
39 + .route("/api/internal/creator/items/{id}", put_csrf_skip(INTERNAL_SKIP, items::update_item))
40 + .route("/api/internal/creator/items/{id}", delete_csrf_skip(INTERNAL_SKIP, items::delete_item))
41 + .route("/api/internal/creator/items/{id}/publish", post_csrf_skip(INTERNAL_SKIP, items::publish_item))
42 + .route("/api/internal/creator/items/{id}/unpublish", post_csrf_skip(INTERNAL_SKIP, items::unpublish_item))
43 + .route_get("/api/internal/creator/items/{id}/versions", get(items::item_versions))
39 44 // Blog posts
40 - .route("/api/internal/creator/projects/{id}/blog", get(content::list_blog_posts))
41 - .route("/api/internal/creator/blog", post(content::create_blog_post))
42 - .route("/api/internal/creator/blog/{id}", delete(content::delete_blog_post))
45 + .route_get("/api/internal/creator/projects/{id}/blog", get(content::list_blog_posts))
46 + .route("/api/internal/creator/blog", post_csrf_skip(INTERNAL_SKIP, content::create_blog_post))
47 + .route("/api/internal/creator/blog/{id}", delete_csrf_skip(INTERNAL_SKIP, content::delete_blog_post))
43 48 // Promo codes
44 - .route("/api/internal/creator/promo-codes", get(content::list_promo_codes).post(content::create_promo_code))
45 - .route("/api/internal/creator/promo-codes/{id}", delete(content::delete_promo_code))
49 + .route("/api/internal/creator/promo-codes", with_csrf_skip(INTERNAL_SKIP, get(content::list_promo_codes).post(content::create_promo_code)))
50 + .route("/api/internal/creator/promo-codes/{id}", delete_csrf_skip(INTERNAL_SKIP, content::delete_promo_code))
46 51 // License keys
47 - .route("/api/internal/creator/items/{id}/keys", get(content::list_license_keys).post(content::generate_license_key))
48 - .route("/api/internal/creator/keys/{id}/revoke", post(content::revoke_license_key))
52 + .route("/api/internal/creator/items/{id}/keys", with_csrf_skip(INTERNAL_SKIP, get(content::list_license_keys).post(content::generate_license_key)))
53 + .route("/api/internal/creator/keys/{id}/revoke", post_csrf_skip(INTERNAL_SKIP, content::revoke_license_key))
49 54 // Analytics + export
50 - .route("/api/internal/creator/analytics", get(creators::creator_analytics))
51 - .route("/api/internal/creator/transactions", get(creators::creator_transactions))
52 - .route("/api/internal/creator/export/sales", get(creators::export_sales))
55 + .route_get("/api/internal/creator/analytics", get(creators::creator_analytics))
56 + .route_get("/api/internal/creator/transactions", get(creators::creator_transactions))
57 + .route_get("/api/internal/creator/export/sales", get(creators::export_sales))
53 58 // Settings
54 - .route("/api/internal/creator/ssh-keys", get(git::list_ssh_keys))
59 + .route_get("/api/internal/creator/ssh-keys", get(git::list_ssh_keys))
55 60 // Git authorization
56 - .route("/api/internal/git/authorize", post(git::git_authorize))
57 - .route("/api/internal/restart-warning", post(git::set_restart_warning))
61 + .route("/api/internal/git/authorize", post_csrf_skip(INTERNAL_SKIP, git::git_authorize))
62 + .route("/api/internal/restart-warning", post_csrf_skip(INTERNAL_SKIP, git::set_restart_warning))
58 63 // CLI features: tags
59 - .route("/api/internal/creator/items/{id}/tags", get(cli_features::list_item_tags))
60 - .route("/api/internal/creator/items/tags", post(cli_features::add_item_tag))
61 - .route("/api/internal/creator/items/tags/remove", post(cli_features::remove_item_tag))
62 - .route("/api/internal/tags/search", get(cli_features::search_tags))
64 + .route_get("/api/internal/creator/items/{id}/tags", get(cli_features::list_item_tags))
65 + .route("/api/internal/creator/items/tags", post_csrf_skip(INTERNAL_SKIP, cli_features::add_item_tag))
66 + .route("/api/internal/creator/items/tags/remove", post_csrf_skip(INTERNAL_SKIP, cli_features::remove_item_tag))
67 + .route_get("/api/internal/tags/search", get(cli_features::search_tags))
63 68 // CLI features: broadcast
64 - .route("/api/internal/creator/broadcast", post(cli_features::send_broadcast))
69 + .route("/api/internal/creator/broadcast", post_csrf_skip(INTERNAL_SKIP, cli_features::send_broadcast))
65 70 // CLI features: tiers
66 - .route("/api/internal/creator/projects/{id}/tiers", get(cli_features::list_tiers))
71 + .route_get("/api/internal/creator/projects/{id}/tiers", get(cli_features::list_tiers))
67 72 // CLI features: collections
68 - .route("/api/internal/creator/collections", get(cli_features::list_collections).post(cli_features::create_collection))
69 - .route("/api/internal/creator/collections/{id}", delete(cli_features::delete_collection))
73 + .route("/api/internal/creator/collections", with_csrf_skip(INTERNAL_SKIP, get(cli_features::list_collections).post(cli_features::create_collection)))
74 + .route("/api/internal/creator/collections/{id}", delete_csrf_skip(INTERNAL_SKIP, cli_features::delete_collection))
70 75 // CLI features: custom domains
71 - .route("/api/internal/creator/domain", get(cli_features::get_domain).post(cli_features::add_domain).delete(cli_features::remove_domain))
72 - .route("/api/internal/creator/domain/verify", post(cli_features::verify_domain))
76 + .route("/api/internal/creator/domain", with_csrf_skip(INTERNAL_SKIP, get(cli_features::get_domain).post(cli_features::add_domain).delete(cli_features::remove_domain)))
77 + .route("/api/internal/creator/domain/verify", post_csrf_skip(INTERNAL_SKIP, cli_features::verify_domain))
73 78 }
@@ -47,8 +47,8 @@ use axum::{
47 47 extract::{Request, State},
48 48 middleware::Next,
49 49 response::{IntoResponse, Response},
50 - routing::{delete, get, options, post, put},
51 - Json, Router,
50 + routing::{get, options},
51 + Json,
52 52 };
53 53 use serde::{Deserialize, Serialize};
54 54 use serde_json::json;
@@ -56,11 +56,15 @@ use tower_governor::GovernorLayer;
56 56
57 57 use crate::{
58 58 constants,
59 + csrf::{delete_csrf, post_csrf, post_csrf_skip, put_csrf, CsrfRouter},
59 60 db::{self, BlogPostId, ItemId, ProjectId, ProjectType, UserId},
60 61 error::{ApiErrorMessage, AppError, Result},
61 62 AppState,
62 63 };
63 64
65 + const LICENSE_BEARER_SKIP: &str = "license API: bearer license key, no session";
66 + const GUEST_CHECKOUT_SKIP: &str = "guest checkout: pre-auth, no session";
67 +
64 68 /// Fetch a project and verify the user owns it. Shared by all ownership checks
65 69 /// that go through a project (items, blog posts, direct project access).
66 70 pub(super) async fn verify_project_ownership(
@@ -171,204 +175,204 @@ async fn email_signup(
171 175 /// - Write routes (POST/PUT/DELETE): burst 10, 2/sec per IP
172 176 /// - Export routes: burst 3, 1/sec per IP (stricter, prevents bulk extraction)
173 177 /// - Read routes (GET): no rate limit (alpha scale)
174 - pub fn api_routes() -> Router<AppState> {
178 + pub fn api_routes() -> CsrfRouter<AppState> {
175 179 let write_rate_limit = crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST);
176 180 let export_rate_limit = crate::helpers::rate_limiter_per_sec(constants::API_EXPORT_RATE_LIMIT_PER_SEC, constants::API_EXPORT_RATE_LIMIT_BURST);
177 181
178 182 // Write routes — rate limited
179 - let write_routes = Router::new()
183 + let write_routes = CsrfRouter::new()
180 184 // User routes
181 - .route("/api/users/me", put(users::update_profile))
182 - .route("/api/users/me/password", put(users::update_password))
183 - .route("/api/users/me/preferences", put(users::update_preferences))
184 - .route("/api/users/me/stripe", delete(users::disconnect_stripe))
185 - .route("/api/users/me/stripe-tax", put(users::toggle_stripe_tax))
186 - .route("/api/users/me", delete(users::delete_account))
187 - .route("/api/users/me/deactivate", post(users::deactivate_account))
188 - .route("/api/users/me/reactivate", post(users::reactivate_account))
189 - .route("/api/users/me/pause-creator", post(users::pause_creator))
185 + .route("/api/users/me", put_csrf(users::update_profile))
186 + .route("/api/users/me/password", put_csrf(users::update_password))
187 + .route("/api/users/me/preferences", put_csrf(users::update_preferences))
188 + .route("/api/users/me/stripe", delete_csrf(users::disconnect_stripe))
189 + .route("/api/users/me/stripe-tax", put_csrf(users::toggle_stripe_tax))
190 + .route("/api/users/me", delete_csrf(users::delete_account))
191 + .route("/api/users/me/deactivate", post_csrf(users::deactivate_account))
192 + .route("/api/users/me/reactivate", post_csrf(users::reactivate_account))
193 + .route("/api/users/me/pause-creator", post_csrf(users::pause_creator))
190 194 // Broadcast
191 - .route("/api/broadcast", post(users::broadcast_send))
192 - .route("/api/support/ticket", post(users::submit_support_ticket))
195 + .route("/api/broadcast", post_csrf(users::broadcast_send))
196 + .route("/api/support/ticket", post_csrf(users::submit_support_ticket))
193 197 // Project routes
194 - .route("/api/projects", post(projects::create_project))
195 - .route("/api/projects/{id}", put(projects::update_project))
196 - .route("/api/projects/{id}", delete(projects::delete_project))
198 + .route("/api/projects", post_csrf(projects::create_project))
199 + .route("/api/projects/{id}", put_csrf(projects::update_project))
200 + .route("/api/projects/{id}", delete_csrf(projects::delete_project))
197 201 // Git repo management
198 - .route("/api/repos", post(projects::create_repo))
199 - .route("/api/repos/{id}/visibility", put(projects::update_repo_visibility))
200 - .route("/api/repos/{id}/collaborators", get(projects::list_repo_collaborators))
201 - .route("/api/repos/{id}/collaborators", post(projects::add_repo_collaborator))
202 - .route("/api/repos/{repo_id}/collaborators/{user_id}", delete(projects::remove_repo_collaborator))
203 - .route("/api/projects/{id}/repos", post(projects::link_repo))
204 - .route("/api/projects/{id}/repos/{repo_name}", delete(projects::unlink_repo))
202 + .route("/api/repos", post_csrf(projects::create_repo))
203 + .route("/api/repos/{id}/visibility", put_csrf(projects::update_repo_visibility))
204 + .route_get("/api/repos/{id}/collaborators", get(projects::list_repo_collaborators))
205 + .route("/api/repos/{id}/collaborators", post_csrf(projects::add_repo_collaborator))
206 + .route("/api/repos/{repo_id}/collaborators/{user_id}", delete_csrf(projects::remove_repo_collaborator))
207 + .route("/api/projects/{id}/repos", post_csrf(projects::link_repo))
208 + .route("/api/projects/{id}/repos/{repo_name}", delete_csrf(projects::unlink_repo))
205 209 // Project members
206 - .route("/api/projects/{id}/members", post(projects::add_project_member))
207 - .route("/api/projects/{project_id}/members/{user_id}", delete(projects::remove_project_member))
210 + .route("/api/projects/{id}/members", post_csrf(projects::add_project_member))
211 + .route("/api/projects/{project_id}/members/{user_id}", delete_csrf(projects::remove_project_member))
208 212 // Item routes
209 - .route("/api/projects/{id}/items", post(items::create_item))
210 - .route("/api/items/{id}", put(items::update_item))
211 - .route("/api/items/{id}", delete(items::delete_item))
212 - .route("/api/items/{id}/duplicate", post(items::duplicate_item))
213 + .route("/api/projects/{id}/items", post_csrf(items::create_item))
214 + .route("/api/items/{id}", put_csrf(items::update_item))
215 + .route("/api/items/{id}", delete_csrf(items::delete_item))
216 + .route("/api/items/{id}/duplicate", post_csrf(items::duplicate_item))
213 217 // Bulk item operations
214 - .route("/api/items/bulk/publish", post(items::bulk_publish))
215 - .route("/api/items/bulk/unpublish", post(items::bulk_unpublish))
216 - .route("/api/items/bulk/delete", post(items::bulk_delete))
217 - .route("/api/items/bulk/price", post(items::bulk_price))
218 - .route("/api/items/bulk/tag", post(items::bulk_tag))
219 - .route("/api/items/{id}/move", put(items::move_item))
218 + .route("/api/items/bulk/publish", post_csrf(items::bulk_publish))
219 + .route("/api/items/bulk/unpublish", post_csrf(items::bulk_unpublish))
220 + .route("/api/items/bulk/delete", post_csrf(items::bulk_delete))
221 + .route("/api/items/bulk/price", post_csrf(items::bulk_price))
222 + .route("/api/items/bulk/tag", post_csrf(items::bulk_tag))
223 + .route("/api/items/{id}/move", put_csrf(items::move_item))
220 224 // Bundle management
221 - .route("/api/items/{id}/bundle/add", post(items::bundle_add))
222 - .route("/api/items/{id}/bundle/create-child", post(items::bundle_create_child))
223 - .route("/api/items/{id}/bundle/{child_id}", delete(items::bundle_remove))
224 - .route("/api/items/{id}/bundle/{child_id}/listed", put(items::bundle_toggle_listed))
225 + .route("/api/items/{id}/bundle/add", post_csrf(items::bundle_add))
226 + .route("/api/items/{id}/bundle/create-child", post_csrf(items::bundle_create_child))
227 + .route("/api/items/{id}/bundle/{child_id}", delete_csrf(items::bundle_remove))
228 + .route("/api/items/{id}/bundle/{child_id}/listed", put_csrf(items::bundle_toggle_listed))
225 229 // Refund
226 - .route("/api/items/{id}/refund", post(items::refund_transaction))
227 - .route("/api/items/{id}/restore", post(items::restore_item))
230 + .route("/api/items/{id}/refund", post_csrf(items::refund_transaction))
231 + .route("/api/items/{id}/restore", post_csrf(items::restore_item))
228 232 // Tag routes (HTMX)
229 - .route("/api/items/{id}/tags", post(items::add_tag))
230 - .route("/api/items/{id}/tags/{tag_id}", delete(items::remove_tag))
231 - .route("/api/items/{id}/primary-tag", put(items::set_primary_tag))
233 + .route("/api/items/{id}/tags", post_csrf(items::add_tag))
234 + .route("/api/items/{id}/tags/{tag_id}", delete_csrf(items::remove_tag))
235 + .route("/api/items/{id}/primary-tag", put_csrf(items::set_primary_tag))
232 236 // Text content route
233 - .route("/api/items/{id}/text", put(items::update_item_text))
237 + .route("/api/items/{id}/text", put_csrf(items::update_item_text))
234 238 // Version routes
235 - .route("/api/items/{id}/versions", post(items::create_version))
236 - .route("/api/items/{id}/versions/{version_id}", delete(items::delete_version))
239 + .route("/api/items/{id}/versions", post_csrf(items::create_version))
240 + .route("/api/items/{id}/versions/{version_id}", delete_csrf(items::delete_version))
237 241 // Custom link routes
238 - .route("/api/links", post(links::create_link))
239 - .route("/api/links/{id}", put(links::update_link))
240 - .route("/api/links/{id}", delete(links::delete_link))
241 - .route("/api/links/reorder", put(links::reorder_links))
242 + .route("/api/links", post_csrf(links::create_link))
243 + .route("/api/links/{id}", put_csrf(links::update_link))
244 + .route("/api/links/{id}", delete_csrf(links::delete_link))
245 + .route("/api/links/reorder", put_csrf(links::reorder_links))
242 246 // Chapter routes
243 - .route("/api/items/{id}/chapters", post(items::create_chapter))
244 - .route("/api/chapters/{id}", put(items::update_chapter))
245 - .route("/api/chapters/{id}", delete(items::delete_chapter))
247 + .route("/api/items/{id}/chapters", post_csrf(items::create_chapter))
248 + .route("/api/chapters/{id}", put_csrf(items::update_chapter))
249 + .route("/api/chapters/{id}", delete_csrf(items::delete_chapter))
246 250 // Section routes
247 - .route("/api/items/{id}/sections", post(items::create_section))
248 - .route("/api/sections/{id}", put(items::update_section))
249 - .route("/api/sections/{id}", delete(items::delete_section))
250 - .route("/api/items/{id}/sections/reorder", put(items::reorder_sections))
251 + .route("/api/items/{id}/sections", post_csrf(items::create_section))
252 + .route("/api/sections/{id}", put_csrf(items::update_section))
253 + .route("/api/sections/{id}", delete_csrf(items::delete_section))
254 + .route("/api/items/{id}/sections/reorder", put_csrf(items::reorder_sections))
251 255 // Project section routes
252 - .route("/api/projects/{id}/sections", post(project_sections::create_section))
253 - .route("/api/project-sections/{id}", put(project_sections::update_section))
254 - .route("/api/project-sections/{id}", delete(project_sections::delete_section))
255 - .route("/api/projects/{id}/sections/reorder", put(project_sections::reorder_sections))
256 + .route("/api/projects/{id}/sections", post_csrf(project_sections::create_section))
257 + .route("/api/project-sections/{id}", put_csrf(project_sections::update_section))
258 + .route("/api/project-sections/{id}", delete_csrf(project_sections::delete_section))
259 + .route("/api/projects/{id}/sections/reorder", put_csrf(project_sections::reorder_sections))
256 260 // Library routes
257 - .route("/api/library/add/{item_id}", post(users::add_to_library))
258 - .route("/api/library/remove/{item_id}", delete(users::remove_from_library))
261 + .route("/api/library/add/{item_id}", post_csrf(users::add_to_library))
262 + .route("/api/library/remove/{item_id}", delete_csrf(users::remove_from_library))
259 263 // Contact sharing revocation
260 - .route("/api/contacts/{seller_id}", delete(users::revoke_contact))
264 + .route("/api/contacts/{seller_id}", delete_csrf(users::revoke_contact))
261 265 // Waitlist
262 - .route("/api/waitlist/apply", post(users::waitlist_apply))
266 + .route("/api/waitlist/apply", post_csrf(users::waitlist_apply))
263 267 // Email verification
264 - .route("/api/resend-verification", post(users::resend_verification))
268 + .route("/api/resend-verification", post_csrf(users::resend_verification))
265 269 // Account management
266 - .route("/api/account/request-deletion", post(users::request_account_deletion))
270 + .route("/api/account/request-deletion", post_csrf(users::request_account_deletion))
267 271 // Suspension appeal
268 - .route("/api/users/me/appeal", post(users::submit_appeal))
272 + .route("/api/users/me/appeal", post_csrf(users::submit_appeal))
269 273 // Session management
270 - .route("/api/users/me/sessions/{id}", delete(users::revoke_session))
271 - .route("/api/users/me/sessions", delete(users::revoke_other_sessions))
274 + .route("/api/users/me/sessions/{id}", delete_csrf(users::revoke_session))
275 + .route("/api/users/me/sessions", delete_csrf(users::revoke_other_sessions))
272 276 // Blog routes
273 - .route("/api/projects/{id}/blog", post(blog::create_blog_post))
274 - .route("/api/blog/{id}", put(blog::update_blog_post))
275 - .route("/api/blog/{id}", delete(blog::delete_blog_post))
277 + .route("/api/projects/{id}/blog", post_csrf(blog::create_blog_post))
278 + .route("/api/blog/{id}", put_csrf(blog::update_blog_post))
279 + .route("/api/blog/{id}", delete_csrf(blog::delete_blog_post))
276 280 // License key management (creator)
277 - .route("/api/items/{id}/license-settings", put(license_keys::update_license_settings))
278 - .route("/api/items/{id}/keys", post(license_keys::generate_key))
279 - .route("/api/keys/{id}/revoke", post(license_keys::revoke_key))
281 + .route("/api/items/{id}/license-settings", put_csrf(license_keys::update_license_settings))
282 + .route("/api/items/{id}/keys", post_csrf(license_keys::generate_key))
283 + .route("/api/keys/{id}/revoke", post_csrf(license_keys::revoke_key))
280 284 // Promo code management (creator)
281 - .route("/api/promo-codes", post(promo_codes::create_promo_code))
282 - .route("/api/promo-codes", get(promo_codes::list_promo_codes))
283 - .route("/api/promo-codes/expired", delete(promo_codes::delete_expired_promo_codes))
284 - .route("/api/promo-codes/{id}", put(promo_codes::update_promo_code))
285 - .route("/api/promo-codes/{id}", delete(promo_codes::delete_promo_code))
286 - .route("/api/promo-codes/{id}/redemptions", get(promo_codes::list_redemptions))
285 + .route("/api/promo-codes", post_csrf(promo_codes::create_promo_code))
286 + .route_get("/api/promo-codes", get(promo_codes::list_promo_codes))
287 + .route("/api/promo-codes/expired", delete_csrf(promo_codes::delete_expired_promo_codes))
288 + .route("/api/promo-codes/{id}", put_csrf(promo_codes::update_promo_code))
289 + .route("/api/promo-codes/{id}", delete_csrf(promo_codes::delete_promo_code))
290 + .route_get("/api/promo-codes/{id}/redemptions", get(promo_codes::list_redemptions))
287 291 // Promo code claim (buyer — free_access codes)
288 - .route("/api/promo-codes/claim", post(promo_codes::claim_promo_code))
292 + .route("/api/promo-codes/claim", post_csrf(promo_codes::claim_promo_code))
289 293 // Subscription tier management (creator)
290 - .route("/api/projects/{id}/tiers", post(subscriptions::create_tier))
291 - .route("/api/tiers/{id}", put(subscriptions::update_tier))
292 - .route("/api/tiers/{id}", delete(subscriptions::delete_tier))
294 + .route("/api/projects/{id}/tiers", post_csrf(subscriptions::create_tier))
295 + .route("/api/tiers/{id}", put_csrf(subscriptions::update_tier))
296 + .route("/api/tiers/{id}", delete_csrf(subscriptions::delete_tier))
293 297 // Follow system
294 - .route("/api/follow/{target_type}/{target_id}", post(follows::follow_target))
295 - .route("/api/follow/{target_type}/{target_id}", delete(follows::unfollow_target))
298 + .route("/api/follow/{target_type}/{target_id}", post_csrf(follows::follow_target))
299 + .route("/api/follow/{target_type}/{target_id}", delete_csrf(follows::unfollow_target))
296 300 // Category management
297 - .route("/api/categories", post(categories::create_category))
301 + .route("/api/categories", post_csrf(categories::create_category))
298 302 // TOTP 2FA management
299 - .route("/api/users/me/totp/setup", post(totp::setup))
300 - .route("/api/users/me/totp/confirm", post(totp::confirm))
301 - .route("/api/users/me/totp/disable", post(totp::disable))
302 - .route("/api/users/me/totp/backup-codes", post(totp::regenerate_backup_codes))
303 + .route("/api/users/me/totp/setup", post_csrf(totp::setup))
304 + .route("/api/users/me/totp/confirm", post_csrf(totp::confirm))
305 + .route("/api/users/me/totp/disable", post_csrf(totp::disable))
306 + .route("/api/users/me/totp/backup-codes", post_csrf(totp::regenerate_backup_codes))
303 307 // Passkey management
304 - .route("/api/users/me/passkeys/register/start", post(passkeys::register_start))
305 - .route("/api/users/me/passkeys/register/finish", post(passkeys::register_finish))
306 - .route("/api/users/me/passkeys/{id}", put(passkeys::rename))
307 - .route("/api/users/me/passkeys/{id}", delete(passkeys::delete))
308 + .route("/api/users/me/passkeys/register/start", post_csrf(passkeys::register_start))
309 + .route("/api/users/me/passkeys/register/finish", post_csrf(passkeys::register_finish))
310 + .route("/api/users/me/passkeys/{id}", put_csrf(passkeys::rename))
311 + .route("/api/users/me/passkeys/{id}", delete_csrf(passkeys::delete))
308 312 // Content insertion management
309 - .route("/api/users/me/insertions/presign", post(content_insertions::presign_insertion))
310 - .route("/api/users/me/insertions/confirm", post(content_insertions::confirm_insertion))
311 - .route("/api/insertions/{id}", put(content_insertions::rename_insertion))
312 - .route("/api/insertions/{id}", delete(content_insertions::delete_insertion))
313 + .route("/api/users/me/insertions/presign", post_csrf(content_insertions::presign_insertion))
314 + .route("/api/users/me/insertions/confirm", post_csrf(content_insertions::confirm_insertion))
315 + .route("/api/insertions/{id}", put_csrf(content_insertions::rename_insertion))
316 + .route("/api/insertions/{id}", delete_csrf(content_insertions::delete_insertion))
313 317 // Content insertion placements
314 - .route("/api/items/{id}/insertions", post(content_insertions::create_placement))
315 - .route("/api/item-insertions/{id}", delete(content_insertions::delete_placement))
318 + .route("/api/items/{id}/insertions", post_csrf(content_insertions::create_placement))
319 + .route("/api/item-insertions/{id}", delete_csrf(content_insertions::delete_placement))
316 320 // SSH key management
317 - .route("/api/users/me/ssh-keys", get(ssh_keys::list_keys))
318 - .route("/api/users/me/ssh-keys", post(ssh_keys::add_key))
319 - .route("/api/users/me/ssh-keys/{id}", delete(ssh_keys::delete_key))
321 + .route_get("/api/users/me/ssh-keys", get(ssh_keys::list_keys))
322 + .route("/api/users/me/ssh-keys", post_csrf(ssh_keys::add_key))
323 + .route("/api/users/me/ssh-keys/{id}", delete_csrf(ssh_keys::delete_key))
320 324 // Reports
321 - .route("/api/reports", post(reports::submit_report))
325 + .route("/api/reports", post_csrf(reports::submit_report))
322 326 // Collections
323 - .route("/api/collections", post(collections::create_collection))
324 - .route("/api/collections/{id}", put(collections::update_collection))
325 - .route("/api/collections/{id}", delete(collections::delete_collection))
326 - .route("/api/collections/{id}/items/{item_id}", post(collections::add_item))
327 - .route("/api/collections/{id}/items/{item_id}", delete(collections::remove_item))
328 - .route("/api/collections/{id}/items/reorder", put(collections::reorder_items))
327 + .route("/api/collections", post_csrf(collections::create_collection))
328 + .route("/api/collections/{id}", put_csrf(collections::update_collection))
329 + .route("/api/collections/{id}", delete_csrf(collections::delete_collection))
330 + .route("/api/collections/{id}/items/{item_id}", post_csrf(collections::add_item))
331 + .route("/api/collections/{id}/items/{item_id}", delete_csrf(collections::remove_item))
332 + .route("/api/collections/{id}/items/reorder", put_csrf(collections::reorder_items))
329 333 // Wishlists
330 - .route("/api/wishlists/{item_id}", post(wishlists::toggle_wishlist))
334 + .route("/api/wishlists/{item_id}", post_csrf(wishlists::toggle_wishlist))
331 335 // Cart
332 - .route("/api/cart/{item_id}", post(cart::toggle_cart))
333 - .route("/api/cart/{item_id}", put(cart::update_cart_amount))
334 - .route("/api/cart/{item_id}", delete(cart::remove_from_cart))
336 + .route("/api/cart/{item_id}", post_csrf(cart::toggle_cart))
337 + .route("/api/cart/{item_id}", put_csrf(cart::update_cart_amount))
338 + .route("/api/cart/{item_id}", delete_csrf(cart::remove_from_cart))
335 339 // Custom domains
336 - .route("/api/domains", post(domains::add_domain))
337 - .route("/api/domains/verify", post(domains::verify_domain))
338 - .route("/api/domains/{id}", delete(domains::remove_domain))
340 + .route("/api/domains", post_csrf(domains::add_domain))
341 + .route("/api/domains/verify", post_csrf(domains::verify_domain))
342 + .route("/api/domains/{id}", delete_csrf(domains::remove_domain))
339 343 // Invite codes
340 - .route("/api/invites/create", post(users::create_invite))
344 + .route("/api/invites/create", post_csrf(users::create_invite))
341 345 // Email signup (public, landing page notify-me)
342 - .route("/api/email-signup", post(email_signup))
346 + .route("/api/email-signup", post_csrf_skip("pre-auth landing signup, no session", email_signup))
343 347 .route_layer(GovernorLayer {
344 348 config: write_rate_limit,
345 349 });
346 350
347 351 // Export routes — stricter rate limit
348 - let export_routes = Router::new()
349 - .route("/api/export/projects", post(exports::export_projects))
350 - .route("/api/export/sales", post(exports::export_sales))
351 - .route("/api/export/purchases", post(exports::export_purchases))
352 - .route("/api/export/splits", post(exports::export_splits))
353 - .route("/api/export/followers", post(exports::export_followers))
354 - .route("/api/export/subscriptions", post(exports::export_subscriptions))
355 - .route("/api/export/content", post(exports::export_content))
356 - .route("/api/export/contacts", post(exports::export_contacts))
352 + let export_routes = CsrfRouter::new()
353 + .route("/api/export/projects", post_csrf(exports::export_projects))
354 + .route("/api/export/sales", post_csrf(exports::export_sales))
355 + .route("/api/export/purchases", post_csrf(exports::export_purchases))
356 + .route("/api/export/splits", post_csrf(exports::export_splits))
357 + .route("/api/export/followers", post_csrf(exports::export_followers))
358 + .route("/api/export/subscriptions", post_csrf(exports::export_subscriptions))
359 + .route("/api/export/content", post_csrf(exports::export_content))
360 + .route("/api/export/contacts", post_csrf(exports::export_contacts))
357 361 .route_layer(GovernorLayer {
358 362 config: export_rate_limit,
359 363 });
360 364
361 365 let key_rate_limit = crate::helpers::rate_limiter_ms(constants::LICENSE_KEY_RATE_LIMIT_MS, constants::LICENSE_KEY_RATE_LIMIT_BURST);
362 366
363 - let key_routes = Router::new()
364 - .route("/api/keys/validate", post(license_keys::validate_key))
365 - .route("/api/v1/keys/validate", post(license_keys::validate_key))
366 - .route("/api/keys/deactivate", post(license_keys::deactivate_key))
367 - .route("/api/v1/keys/deactivate", post(license_keys::deactivate_key))
368 - .route("/api/keys/{key_code}/status", get(license_keys::key_status))
369 - .route("/api/v1/keys/{key_code}/status", get(license_keys::key_status))
370 - .route("/api/v1/license/verify", post(license_keys::license_verify))
371 - .route("/api/v1/license/deactivate", post(license_keys::license_deactivate))
367 + let key_routes = CsrfRouter::new()
368 + .route("/api/keys/validate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key))
369 + .route("/api/v1/keys/validate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key))
370 + .route("/api/keys/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key))
371 + .route("/api/v1/keys/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key))
372 + .route_get("/api/keys/{key_code}/status", get(license_keys::key_status))
373 + .route_get("/api/v1/keys/{key_code}/status", get(license_keys::key_status))
374 + .route("/api/v1/license/verify", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_verify))
375 + .route("/api/v1/license/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_deactivate))
372 376 .route_layer(GovernorLayer {
373 377 config: key_rate_limit,
374 378 });
@@ -376,54 +380,54 @@ pub fn api_routes() -> Router<AppState> {
376 380 let read_rate_limit = crate::helpers::rate_limiter_ms(constants::API_READ_RATE_LIMIT_MS, constants::API_READ_RATE_LIMIT_BURST);
377 381
378 382 // Read routes
379 - let read_routes = Router::new()
380 - .route("/api/public/projects", get(public_projects))
381 - .route("/api/v1/public/projects", get(public_projects))
382 - .route("/api/projects", get(projects::list_projects))
383 - .route("/api/items/{id}/versions", get(items::list_versions))
384 - .route("/api/items/{id}/chapters", get(items::list_chapters))
385 - .route("/api/items/{id}/sections", get(items::list_sections))
386 - .route("/api/items/{id}/keys", get(license_keys::list_keys))
387 - .route("/api/projects/{id}/sections", get(project_sections::list_sections))
388 - .route("/api/projects/{id}/blog", get(blog::list_blog_posts))
389 - .route("/api/blog/{id}", get(blog::get_blog_post))
390 - .route("/api/tags/search", get(tags::search_tags))
391 - .route("/api/categories/search", get(categories::search_categories))
392 - .route("/api/items/{id}/tag-suggestions", get(tags::suggest_tags))
393 - .route("/api/projects/{id}/tiers", get(subscriptions::list_tiers))
383 + let read_routes = CsrfRouter::new()
384 + .route_get("/api/public/projects", get(public_projects))
385 + .route_get("/api/v1/public/projects", get(public_projects))
386 + .route_get("/api/projects", get(projects::list_projects))
387 + .route_get("/api/items/{id}/versions", get(items::list_versions))
388 + .route_get("/api/items/{id}/chapters", get(items::list_chapters))
389 + .route_get("/api/items/{id}/sections", get(items::list_sections))
390 + .route_get("/api/items/{id}/keys", get(license_keys::list_keys))
391 + .route_get("/api/projects/{id}/sections", get(project_sections::list_sections))
392 + .route_get("/api/projects/{id}/blog", get(blog::list_blog_posts))
393 + .route_get("/api/blog/{id}", get(blog::get_blog_post))
394 + .route_get("/api/tags/search", get(tags::search_tags))
395 + .route_get("/api/categories/search", get(categories::search_categories))
396 + .route_get("/api/items/{id}/tag-suggestions", get(tags::suggest_tags))
397 + .route_get("/api/projects/{id}/tiers", get(subscriptions::list_tiers))
394 398 // TOTP status (HTMX partial for dashboard)
395 - .route("/api/users/me/totp/status", get(totp::status))
399 + .route_get("/api/users/me/totp/status", get(totp::status))
396 400 // Passkey list (HTMX partial for dashboard)
397 - .route("/api/users/me/passkeys", get(passkeys::list))
401 + .route_get("/api/users/me/passkeys", get(passkeys::list))
398 402 // SSH key list (HTMX partial for dashboard)
399 - .route("/api/users/me/ssh-keys/list", get(ssh_keys::list_keys_html))
403 + .route_get("/api/users/me/ssh-keys/list", get(ssh_keys::list_keys_html))
400 404 // Content insertion list (HTMX partials for dashboard)
401 - .route("/api/users/me/insertions", get(content_insertions::list_insertions))
402 - .route("/api/items/{id}/insertions", get(content_insertions::list_placements))
405 + .route_get("/api/users/me/insertions", get(content_insertions::list_insertions))
406 + .route_get("/api/items/{id}/insertions", get(content_insertions::list_placements))
403 407 // Collections (read)
404 - .route("/api/collections/for-item/{item_id}", get(collections::collections_for_item))
408 + .route_get("/api/collections/for-item/{item_id}", get(collections::collections_for_item))
405 409 // Custom domains (read)
406 - .route("/api/domains", get(domains::get_domain))
407 - .route("/api/domains/caddy-ask", get(domains::caddy_ask))
408 - .route("/api/restart-status", get(internal::restart_status))
410 + .route_get("/api/domains", get(domains::get_domain))
411 + .route_get("/api/domains/caddy-ask", get(domains::caddy_ask))
412 + .route_get("/api/restart-status", get(internal::restart_status))
409 413 // Cart (read)
410 - .route("/api/cart/count", get(cart::cart_count))
414 + .route_get("/api/cart/count", get(cart::cart_count))
411 415 // Import system (read)
412 - .route("/api/users/me/import/{id}", get(imports::get_import_status))
413 - .route("/api/users/me/imports", get(imports::list_imports))
416 + .route_get("/api/users/me/import/{id}", get(imports::get_import_status))
417 + .route_get("/api/users/me/imports", get(imports::list_imports))
414 418 // License text (public)
415 - .route("/api/items/{id}/license.txt", get(license_keys::license_text))
416 - .route("/api/v1/items/{id}/license.txt", get(license_keys::license_text))
419 + .route_get("/api/items/{id}/license.txt", get(license_keys::license_text))
420 + .route_get("/api/v1/items/{id}/license.txt", get(license_keys::license_text))
417 421 .route_layer(GovernorLayer {
418 422 config: read_rate_limit,
419 423 });
420 424
421 425 let validate_rate_limit = crate::helpers::rate_limiter_per_sec(constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST);
422 426
423 - let validate_routes = Router::new()
424 - .route("/api/validate/project-slug", post(validate::validate_project_slug))
425 - .route("/api/validate/collection-slug", post(validate::validate_collection_slug))
426 - .route("/api/validate/blog-slug", post(validate::validate_blog_slug))
427 + let validate_routes = CsrfRouter::new()
428 + .route("/api/validate/project-slug", post_csrf(validate::validate_project_slug))
429 + .route("/api/validate/collection-slug", post_csrf(validate::validate_collection_slug))
430 + .route("/api/validate/blog-slug", post_csrf(validate::validate_blog_slug))
427 431 .route_layer(GovernorLayer {
428 432 config: validate_rate_limit,
429 433 });
@@ -431,8 +435,8 @@ pub fn api_routes() -> Router<AppState> {
431 435 // Import route needs a higher body limit (base64-encoded CSV up to 10 MB
432 436 // ≈ 14 MB encoded). The global 1 MB RequestBodyLimitLayer would reject it,
433 437 // so we override with a per-route layer.
434 - let import_routes = Router::new()
435 - .route("/api/users/me/import", post(imports::start_import))
438 + let import_routes = CsrfRouter::new()
439 + .route("/api/users/me/import", post_csrf(imports::start_import))
436 440 .layer(axum::extract::DefaultBodyLimit::max(15 * 1024 * 1024))
437 441 .route_layer(GovernorLayer {
438 442 config: crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST),
@@ -443,18 +447,18 @@ pub fn api_routes() -> Router<AppState> {
443 447 constants::GUEST_CHECKOUT_RATE_LIMIT_PER_SEC,
444 448 constants::GUEST_CHECKOUT_RATE_LIMIT_BURST,
445 449 );
446 - let guest_routes = Router::new()
447 - .route("/api/checkout/guest/{item_id}", post(guest_checkout::create_guest_checkout))
448 - .route("/api/checkout/guest/{item_id}", options(guest_checkout::guest_checkout_preflight))
449 - .route("/api/checkout/guest-free/{item_id}", post(guest_checkout::claim_free_guest))
450 - .route("/api/purchases/claim", post(guest_checkout::claim_purchase))
450 + let guest_routes = CsrfRouter::new()
451 + .route("/api/checkout/guest/{item_id}", post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::create_guest_checkout))
452 + .route_get("/api/checkout/guest/{item_id}", options(guest_checkout::guest_checkout_preflight))
453 + .route("/api/checkout/guest-free/{item_id}", post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::claim_free_guest))
454 + .route("/api/purchases/claim", post_csrf(guest_checkout::claim_purchase))
451 455 .route_layer(GovernorLayer {
452 456 config: guest_checkout_rate_limit,
453 457 });
454 458
455 459 // Guest download route — separate, more lenient rate limit
456 - let download_routes = Router::new()
457 - .route("/download/{token}", get(guest_checkout::guest_download));
460 + let download_routes = CsrfRouter::new()
461 + .route_get("/download/{token}", get(guest_checkout::guest_download));
458 462
459 463 write_routes
460 464 .merge(export_routes)
@@ -6,7 +6,7 @@ use axum::{
6 6 http::{header::HeaderMap, StatusCode},
7 7 response::{Html, IntoResponse, Redirect, Response},
8 8 routing::{get, post},
9 - Form, Router,
9 + Form,
10 10 };
11 11 use serde::Deserialize;
12 12 use tower_governor::GovernorLayer;
@@ -14,6 +14,7 @@ use tower_sessions::{Expiry, Session};
14 14
15 15 use crate::{
16 16 auth::{login_user, logout_user, track_session, verify_password, AuthUser, SessionUser, SESSION_TRACKING_KEY},
17 + csrf::{post_csrf, with_csrf, with_csrf_skip, CsrfRouter},
17 18 constants::{self, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES},
18 19 db::{self, UserSessionId, Username},
19 20 email,
@@ -31,28 +32,37 @@ static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
31 32 });
32 33
33 34 /// Register authentication routes with rate limiting.
34 - pub fn auth_routes() -> Router<AppState> {
35 + pub fn auth_routes() -> CsrfRouter<AppState> {
35 36 let auth_rate_limit = rate_limiter_ms(constants::AUTH_RATE_LIMIT_MS, constants::AUTH_RATE_LIMIT_BURST);
36 37 let validate_rate_limit = rate_limiter_per_sec(constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST);
37 38
38 - Router::new()
39 + CsrfRouter::new()
39 40 // GET /login is NOT rate-limited (page render for CSRF tokens).
40 41 // POST /login and passkey routes ARE rate-limited.
41 - .route("/login", get(crate::routes::pages::public::landing::login_page)
42 - .post(login_handler.layer(GovernorLayer { config: auth_rate_limit.clone() })))
43 - .route("/auth/passkey/start", post(passkey_auth_start)
44 - .layer(GovernorLayer { config: auth_rate_limit.clone() }))
45 - .route("/auth/passkey/finish", post(passkey_auth_finish)
46 - .layer(GovernorLayer { config: auth_rate_limit }))
42 + .route("/login", with_csrf_skip(
43 + "pre-auth login form — Phase 1 entry tracks moving to Manual",
44 + get(crate::routes::pages::public::landing::login_page)
45 + .post(login_handler.layer(GovernorLayer { config: auth_rate_limit.clone() })),
46 + ))
47 + .route("/auth/passkey/start", with_csrf_skip(
48 + "pre-auth WebAuthn challenge",
49 + post(passkey_auth_start)
50 + .layer(GovernorLayer { config: auth_rate_limit.clone() }),
51 + ))
52 + .route("/auth/passkey/finish", with_csrf_skip(
53 + "pre-auth WebAuthn assertion",
54 + post(passkey_auth_finish)
55 + .layer(GovernorLayer { config: auth_rate_limit }),
56 + ))
47 57 // Routes without auth rate limiting
48 - .route("/logout", post(logout_handler))
49 - .route("/auth/me", get(me_handler))
58 + .route("/logout", post_csrf(logout_handler))
59 + .route_get("/auth/me", get(me_handler))
50 60 // Username validation with its own rate limit
51 61 .route(
52 62 "/api/validate/username",
53 - post(validate_username).layer(GovernorLayer {
63 + with_csrf(post(validate_username).layer(GovernorLayer {
54 64 config: validate_rate_limit,
55 - }),
65 + })),
56 66 )
57 67 }
58 68
@@ -8,7 +8,7 @@ use axum::{
8 8 http::StatusCode,
9 9 response::IntoResponse,
10 10 routing::{get, post},
11 - Json, Router,
11 + Json,
12 12 };
13 13 use chrono::{DateTime, Utc};
14 14 use serde::{Deserialize, Serialize};
@@ -16,6 +16,7 @@ use tower_governor::GovernorLayer;
16 16
17 17 use crate::{
18 18 constants,
19 + csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter},
19 20 db::{self, BuildConfigId, BuildId, BuildStatus, GitRepoId, OtaReleaseId, SyncAppId},
20 21 error::{AppError, Result},
21 22 synckit_auth::SyncUser,
@@ -495,32 +496,33 @@ async fn hook_trigger(
495 496 // ── Router ──
496 497
497 498 /// Build the build pipeline route tree.
498 - pub fn build_routes() -> Router<AppState> {
499 + pub fn build_routes() -> CsrfRouter<AppState> {
499 500 let write_rate_limit = crate::helpers::rate_limiter_ms(
500 501 constants::BUILD_WRITE_RATE_LIMIT_MS,
501 502 constants::BUILD_WRITE_RATE_LIMIT_BURST,
502 503 );
503 504
504 - let mgmt_routes = Router::new()
505 + const SYNC_SKIP: &str = "synckit builds: bearer auth, no session";
506 + let mgmt_routes = CsrfRouter::new()
505 507 .route(
506 508 "/api/sync/builds/apps/{app_id}/config",
507 - post(create_config).get(get_config).put(update_config).delete(delete_config),
509 + with_csrf_skip(SYNC_SKIP, post(create_config).get(get_config).put(update_config).delete(delete_config)),
508 510 )
509 511 .route(
510 512 "/api/sync/builds/apps/{app_id}/trigger",
511 - post(manual_trigger),
513 + post_csrf_skip(SYNC_SKIP, manual_trigger),
512 514 )
513 - .route(
515 + .route_get(
514 516 "/api/sync/builds/apps/{app_id}/builds",
515 517 get(list_builds),
516 518 )
517 - .route(
519 + .route_get(
518 520 "/api/sync/builds/apps/{app_id}/builds/{build_id}",
519 521 get(get_build),
520 522 )
521 523 .route(
522 524 "/api/sync/builds/apps/{app_id}/builds/{build_id}/cancel",
523 - post(cancel_build),
525 + post_csrf_skip(SYNC_SKIP, cancel_build),
524 526 )
525 527 .route_layer(GovernorLayer {
526 528 config: write_rate_limit,
@@ -531,8 +533,8 @@ pub fn build_routes() -> Router<AppState> {
531 533 constants::BUILD_TRIGGER_RATE_LIMIT_BURST,
532 534 );
533 535
534 - let internal_routes = Router::new()
535 - .route("/api/internal/builds/trigger", post(hook_trigger))
536 + let internal_routes = CsrfRouter::new()
537 + .route("/api/internal/builds/trigger", post_csrf_skip("internal CI hook: HMAC bearer auth", hook_trigger))
536 538 .route_layer(GovernorLayer {
537 539 config: trigger_rate_limit,
538 540 });
@@ -5,28 +5,26 @@ mod issues;
5 5 mod push_refs;
6 6 mod settings;
7 7
8 - use axum::{
9 - routing::{get, post},
10 - Router,
11 - };
8 + use axum::routing::get;
12 9
13 10 use crate::{
11 + csrf::{post_csrf, post_csrf_skip, CsrfRouter},
14 12 routes::git::{resolve_repo, repos_root},
15 13 AppState,
16 14 };
17 15
18 16 /// Register all git issue routes.
19 - pub fn git_issue_routes() -> Router<AppState> {
20 - Router::new()
17 + pub fn git_issue_routes() -> CsrfRouter<AppState> {
18 + CsrfRouter::new()
21 19 // Issue list + detail (read-only)
22 - .route("/git/{owner}/{repo}/issues", get(issues::issue_list))
23 - .route("/git/{owner}/{repo}/issues/{number}", get(issues::issue_detail))
20 + .route_get("/git/{owner}/{repo}/issues", get(issues::issue_list))
21 + .route_get("/git/{owner}/{repo}/issues/{number}", get(issues::issue_detail))
24 22 // Repo settings
25 - .route("/git/{owner}/{repo}/settings", get(settings::repo_settings_form))
26 - .route("/git/{owner}/{repo}/settings", post(settings::repo_settings_save))
27 - .route("/git/{owner}/{repo}/settings/delete", post(settings::repo_settings_delete))
23 + .route_get("/git/{owner}/{repo}/settings", get(settings::repo_settings_form))
24 + .route("/git/{owner}/{repo}/settings", post_csrf(settings::repo_settings_save))
25 + .route("/git/{owner}/{repo}/settings/delete", post_csrf(settings::repo_settings_delete))
28 26 // Internal: commit-message issue references
29 - .route("/api/internal/issues/process-push", post(push_refs::process_push))
27 + .route("/api/internal/issues/process-push", post_csrf_skip("internal git push hook, HMAC bearer", push_refs::process_push))
30 28 }
31 29
32 30 /// Get the default branch name for a repo (for nav bar links).
@@ -8,9 +8,10 @@ use axum::{
8 8 extract::{Query, State},
9 9 http::StatusCode,
10 10 response::{IntoResponse, Redirect, Response},
11 - routing::{get, post},
12 - Form, Json, Router,
11 + routing::get,
12 + Form, Json,
13 13 };
14 + use crate::csrf::{post_csrf_skip, CsrfRouter};
14 15 use rand::RngCore;
15 16 use serde::{Deserialize, Serialize};
16 17 use sha2::{Digest, Sha256};
@@ -558,24 +559,24 @@ async fn userinfo(
558 559
559 560 // ── Router ──
560 561
561 - pub fn oauth_routes() -> Router<AppState> {
562 + pub fn oauth_routes() -> CsrfRouter<AppState> {
562 563 let authorize_rate_limit = crate::helpers::rate_limiter_ms(constants::OAUTH_RATE_LIMIT_MS, constants::OAUTH_RATE_LIMIT_BURST);
563 564 let token_rate_limit = crate::helpers::rate_limiter_ms(constants::OAUTH_TOKEN_RATE_LIMIT_MS, constants::OAUTH_TOKEN_RATE_LIMIT_BURST);
564 565
565 - let authorize_routes = Router::new()
566 - .route("/oauth/authorize", get(authorize_get))
567 - .route("/oauth/authorize", post(authorize_post))
566 + let authorize_routes = CsrfRouter::new()
567 + .route_get("/oauth/authorize", get(authorize_get))
568 + .route("/oauth/authorize", post_csrf_skip("pre-auth OAuth authorize endpoint", authorize_post))
568 569 .route_layer(GovernorLayer {
569 570 config: authorize_rate_limit,
570 571 });
571 572
572 - let token_routes = Router::new()
573 - .route("/oauth/token", post(token_exchange))
573 + let token_routes = CsrfRouter::new()
574 + .route("/oauth/token", post_csrf_skip("pre-auth OAuth token exchange", token_exchange))
574 575 .route_layer(GovernorLayer {
575 576 config: token_rate_limit,
576 577 });
577 578
578 579 authorize_routes
579 580 .merge(token_routes)
580 - .route("/oauth/userinfo", get(userinfo))
581 + .route_get("/oauth/userinfo", get(userinfo))
581 582 }
@@ -8,8 +8,8 @@
8 8 use axum::{
9 9 extract::{Path, State},
10 10 response::IntoResponse,
11 - routing::{delete, get, post, put},
12 - Json, Router,
11 + routing::{get, post},
12 + Json,
13 13 };
14 14 use chrono::{DateTime, Utc};
15 15 use serde::{Deserialize, Serialize};
@@ -17,6 +17,7 @@ use tower_governor::GovernorLayer;
17 17
18 18 use crate::{
19 19 constants,
20 + csrf::{delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip, CsrfRouter},
20 21 db::{self, OtaReleaseId, SyncAppId},
21 22 error::{AppError, Result},
22 23 synckit_auth::SyncUser,
@@ -422,38 +423,39 @@ async fn artifact_download(
422 423 ///
423 424 /// Management routes use SyncKit JWT auth, rate-limited at write tier.
424 425 /// Public routes (updater check, download) are unauthenticated, rate-limited at read tier.
425 - pub fn ota_routes() -> Router<AppState> {
426 + pub fn ota_routes() -> CsrfRouter<AppState> {
426 427 let write_rate_limit = crate::helpers::rate_limiter_ms(
427 428 constants::OTA_WRITE_RATE_LIMIT_MS,
428 429 constants::OTA_WRITE_RATE_LIMIT_BURST,
429 430 );
430 431
431 - let mgmt_routes = Router::new()
432 - .route("/api/sync/ota/apps/{app_id}/slug", put(set_slug))
433 - .route("/api/v1/sync/ota/apps/{app_id}/slug", put(set_slug))
432 + const OTA_SKIP: &str = "synckit OTA: bearer auth, no session";
433 + let mgmt_routes = CsrfRouter::new()
434 + .route("/api/sync/ota/apps/{app_id}/slug", put_csrf_skip(OTA_SKIP, set_slug))
435 + .route("/api/v1/sync/ota/apps/{app_id}/slug", put_csrf_skip(OTA_SKIP, set_slug))
434 436 .route(
435 437 "/api/sync/ota/apps/{app_id}/releases",
436 - post(create_release).get(list_releases),
438 + with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)),
437 439 )
438 440 .route(
439 441 "/api/v1/sync/ota/apps/{app_id}/releases",
440 - post(create_release).get(list_releases),
442 + with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)),
441 443 )
442 444 .route(
443 445 "/api/sync/ota/apps/{app_id}/releases/{release_id}",
444 - delete(delete_release_handler),
446 + delete_csrf_skip(OTA_SKIP, delete_release_handler),
445 447 )
446 448 .route(
447 449 "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}",
448 - delete(delete_release_handler),
450 + delete_csrf_skip(OTA_SKIP, delete_release_handler),
449 451 )
450 452 .route(
451 453 "/api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts",
452 - post(upload_artifact),
454 + post_csrf_skip(OTA_SKIP, upload_artifact),
453 455 )
454 456 .route(
455 457 "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts",
456 - post(upload_artifact),
458 + post_csrf_skip(OTA_SKIP, upload_artifact),
457 459 )
458 460 .route_layer(GovernorLayer {
459 461 config: write_rate_limit,
@@ -464,20 +466,20 @@ pub fn ota_routes() -> Router<AppState> {
464 466 constants::OTA_READ_RATE_LIMIT_BURST,
465 467 );
466 468
467 - let public_routes = Router::new()
468 - .route(
469 + let public_routes = CsrfRouter::new()
470 + .route_get(
469 471 "/api/sync/ota/{slug}/{target}/{arch}/{current_version}",
470 472 get(updater_check),
471 473 )
472 - .route(
474 + .route_get(
473 475 "/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}",
474 476 get(updater_check),
475 477 )
476 - .route(
478 + .route_get(
477 479 "/api/sync/ota/{slug}/download/{release_id}/{target}/{arch}",
478 480 get(artifact_download),
479 481 )
480 - .route(
482 + .route_get(
481 483 "/api/v1/sync/ota/{slug}/download/{release_id}/{target}/{arch}",
482 484 get(artifact_download),
483 485 )
@@ -6,11 +6,11 @@ mod project_tabs;
6 6 mod tabs;
7 7 pub mod wizards;
8 8
9 - use axum::{routing::{get, post}, Router};
9 + use axum::routing::get;
10 10 use serde::Deserialize;
11 11 use tower_governor::GovernorLayer;
12 12
13 - use crate::{constants, db, db::Cents, types::*, AppState};
13 + use crate::{constants, csrf::{post_csrf, CsrfRouter}, db, db::Cents, types::*, AppState};
14 14
15 15 /// Query parameters for analytics time range selection.
16 16 #[derive(Deserialize)]
@@ -38,63 +38,63 @@ pub(super) fn build_chart_bars(buckets: &[db::analytics::TimeBucket]) -> Vec<Cha
38 38 }
39 39
40 40 /// Register dashboard page routes.
41 - pub fn dashboard_routes() -> Router<AppState> {
41 + pub fn dashboard_routes() -> CsrfRouter<AppState> {
42 42 let read_rate_limit = crate::helpers::rate_limiter_ms(
43 43 constants::DASHBOARD_READ_RATE_LIMIT_MS,
44 44 constants::DASHBOARD_READ_RATE_LIMIT_BURST,
45 45 );
46 46
47 47 // Tab endpoints — rate limited to prevent rapid polling
48 - let tab_routes = Router::new()
49 - .route("/dashboard/tabs/details", get(tabs::dashboard_tab_details))
50 - .route("/dashboard/tabs/settings", get(tabs::dashboard_tab_settings))
51 - .route("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile))
52 - .route("/dashboard/tabs/account", get(tabs::dashboard_tab_account))
53 - .route("/dashboard/tabs/payments", get(tabs::dashboard_tab_payments))
54 - .route("/dashboard/tabs/projects", get(tabs::dashboard_tab_projects))
55 - .route("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator))
56 - .route("/dashboard/tabs/analytics", get(tabs::dashboard_tab_analytics))
57 - .route("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit))
58 - .route("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums))
59 - .route("/dashboard/tabs/media", get(tabs::dashboard_tab_media))
60 - .route("/dashboard/tabs/ssh-keys", get(tabs::dashboard_tab_ssh_keys))
61 - .route("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
62 - .route("/dashboard/tabs/contacts", get(tabs::dashboard_tab_contacts))
63 - .route("/dashboard/transactions", get(tabs::dashboard_transactions))
64 - .route("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview))
65 - .route("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content))
66 - .route("/dashboard/project/{slug}/tabs/analytics", get(project_tabs::project_tab_analytics))
67 - .route("/dashboard/project/{slug}/tabs/code", get(project_tabs::project_tab_code))
68 - .route("/dashboard/project/{slug}/tabs/settings", get(project_tabs::project_tab_settings))
69 - .route("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog))
70 - .route("/dashboard/project/{slug}/tabs/monetization", get(project_tabs::project_tab_monetization))
71 - .route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions))
72 - .route("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions))
73 - .route("/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members))
74 - .route("/dashboard/project/{slug}/tabs/synckit", get(project_tabs::project_tab_synckit))
75 - .route("/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview))
76 - .route("/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details))
77 - .route("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing))
78 - .route("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files))
79 - .route("/dashboard/item/{id}/tabs/settings", get(tabs::item_tab_settings))
80 - .route("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales))
81 - .route("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed))
82 - .route("/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics))
48 + let tab_routes = CsrfRouter::new()
49 + .route_get("/dashboard/tabs/details", get(tabs::dashboard_tab_details))
50 + .route_get("/dashboard/tabs/settings", get(tabs::dashboard_tab_settings))
51 + .route_get("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile))
52 + .route_get("/dashboard/tabs/account", get(tabs::dashboard_tab_account))
53 + .route_get("/dashboard/tabs/payments", get(tabs::dashboard_tab_payments))
54 + .route_get("/dashboard/tabs/projects", get(tabs::dashboard_tab_projects))
55 + .route_get("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator))
56 + .route_get("/dashboard/tabs/analytics", get(tabs::dashboard_tab_analytics))
57 + .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit))
58 + .route_get("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums))
59 + .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media))
60 + .route_get("/dashboard/tabs/ssh-keys", get(tabs::dashboard_tab_ssh_keys))
61 + .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
62 + .route_get("/dashboard/tabs/contacts", get(tabs::dashboard_tab_contacts))
63 + .route_get("/dashboard/transactions", get(tabs::dashboard_transactions))
64 + .route_get("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview))
65 + .route_get("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content))
66 + .route_get("/dashboard/project/{slug}/tabs/analytics", get(project_tabs::project_tab_analytics))
67 + .route_get("/dashboard/project/{slug}/tabs/code", get(project_tabs::project_tab_code))
68 + .route_get("/dashboard/project/{slug}/tabs/settings", get(project_tabs::project_tab_settings))
69 + .route_get("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog))
70 + .route_get("/dashboard/project/{slug}/tabs/monetization", get(project_tabs::project_tab_monetization))
71 + .route_get("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions))
72 + .route_get("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions))
73 + .route_get("/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members))
74 + .route_get("/dashboard/project/{slug}/tabs/synckit", get(project_tabs::project_tab_synckit))
75 + .route_get("/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview))
76 + .route_get("/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details))
77 + .route_get("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing))
78 + .route_get("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files))
79 + .route_get("/dashboard/item/{id}/tabs/settings", get(tabs::item_tab_settings))
80 + .route_get("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales))
81 + .route_get("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed))
82 + .route_get("/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics))
83 83 .route_layer(GovernorLayer { config: read_rate_limit });
84 84
85 - Router::new()
85 + CsrfRouter::new()
86 86 .merge(wizards::wizard_routes())
87 - .route("/dashboard", get(main::dashboard))
88 - .route("/dashboard/project/{slug}", get(main::dashboard_project))
89 - .route("/dashboard/item/{id}", get(main::dashboard_item))
87 + .route_get("/dashboard", get(main::dashboard))
88 + .route_get("/dashboard/project/{slug}", get(main::dashboard_project))
89 + .route_get("/dashboard/item/{id}", get(main::dashboard_item))
90 90 .merge(tab_routes)
91 - .route("/dashboard/item/{id}/edit-row", get(forms::item_edit_row))
92 - .route("/dashboard/project/{slug}/blog/new", get(forms::blog_editor))
93 - .route("/dashboard/export", get(forms::export_portal))
94 - .route("/dashboard/import", get(forms::import_portal))
95 - .route("/dashboard/delete-account", get(forms::delete_account_page))
96 - .route("/dashboard/onboarding/dismiss", post(main::dismiss_onboarding))
97 - .route("/dashboard/onboarding/restore", post(main::restore_onboarding))
91 + .route_get("/dashboard/item/{id}/edit-row", get(forms::item_edit_row))
92 + .route_get("/dashboard/project/{slug}/blog/new", get(forms::blog_editor))
93 + .route_get("/dashboard/export", get(forms::export_portal))
94 + .route_get("/dashboard/import", get(forms::import_portal))
95 + .route_get("/dashboard/delete-account", get(forms::delete_account_page))
96 + .route("/dashboard/onboarding/dismiss", post_csrf(main::dismiss_onboarding))
97 + .route("/dashboard/onboarding/restore", post_csrf(main::restore_onboarding))
98 98 }
99 99
100 100 /// Query parameters for filtering dashboard transactions.
@@ -8,8 +8,11 @@
8 8 pub mod item;
9 9 pub mod project;
10 10
11 - use axum::{routing::get, Router};
12 - use crate::AppState;
11 + use axum::routing::get;
12 + use crate::{
13 + csrf::{post_csrf, with_csrf, CsrfRouter},
14 + AppState,
15 + };
13 16
14 17 /// Step navigation helpers shared by both wizards.
15 18 pub fn next_step<'a>(steps: &'a [&str], current: &str) -> Option<&'a str> {
@@ -52,29 +55,29 @@ pub fn build_step_nav(
52 55 }
53 56
54 57 /// Register all wizard routes.
55 - pub fn wizard_routes() -> Router<AppState> {
56 - Router::new()
58 + pub fn wizard_routes() -> CsrfRouter<AppState> {
59 + CsrfRouter::new()
57 60 // Project wizard
58 - .route("/dashboard/new-project", get(project::wizard_page))
61 + .route_get("/dashboard/new-project", get(project::wizard_page))
59 62 .route(
60 63 "/dashboard/new-project/step/basics",
61 - axum::routing::post(project::step_basics_create),
64 + post_csrf(project::step_basics_create),
62 65 )
63 66 .route(
64 67 "/dashboard/new-project/{slug}/step/{step}",
65 - get(project::step_load).post(project::step_save),
68 + with_csrf(get(project::step_load).post(project::step_save)),
66 69 )
67 70 // Item wizard
68 - .route(
71 + .route_get(
69 72 "/dashboard/project/{slug}/new-item",
70 73 get(item::wizard_page),
71 74 )
72 75 .route(
73 76 "/dashboard/project/{slug}/new-item/step/type",
74 - axum::routing::post(item::step_type_create),
77 + post_csrf(item::step_type_create),
75 78 )
76 79 .route(
77 80 "/dashboard/project/{slug}/new-item/{id}/step/{step}",
78 - get(item::step_load).post(item::step_save),
81 + with_csrf(get(item::step_load).post(item::step_save)),
79 82 )
80 83 }
@@ -12,9 +12,9 @@ use crate::AppState;
12 12
13 13 pub fn page_routes() -> Router<AppState> {
14 14 Router::new()
15 - .merge(public::public_routes())
15 + .merge(public::public_routes().finalize())
16 16 .merge(sandbox::sandbox_routes())
17 - .merge(dashboard::dashboard_routes())
17 + .merge(dashboard::dashboard_routes().finalize())
18 18 .merge(email_actions::email_action_routes())
19 19 .merge(feeds::feed_routes())
20 20 .merge(blog::blog_routes())
@@ -13,13 +13,13 @@ use axum::{
13 13 extract::State,
14 14 response::IntoResponse,
15 15 routing::get,
16 - Router,
17 16 };
18 17 use tower_sessions::Session;
19 18
20 19 use crate::{
21 20 auth::MaybeUserUnverified,
22 21 constants,
22 + csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter},
23 23 db,
24 24 error::Result,
25 25 helpers::get_csrf_token,
@@ -31,62 +31,71 @@ use crate::{
31 31 use tower_governor::GovernorLayer;
32 32
33 33 /// Register public page routes.
34 - pub fn public_routes() -> Router<AppState> {
34 + pub fn public_routes() -> CsrfRouter<AppState> {
35 35 let twofa_rate_limit = crate::helpers::rate_limiter_ms(constants::TWO_FACTOR_RATE_LIMIT_MS, constants::TWO_FACTOR_RATE_LIMIT_BURST);
36 36 let join_rate_limit = crate::helpers::rate_limiter_ms(constants::AUTH_RATE_LIMIT_MS, constants::AUTH_RATE_LIMIT_BURST);
37 37
38 - Router::new()
39 - .route("/", get(landing::index))
40 - .route("/library", get(landing::library))
41 - .route("/cart", get(landing::cart_page))
42 - .route("/library/tabs/purchases", get(landing::library_tab_purchases))
43 - .route("/library/tabs/feed", get(landing::library_tab_feed))
44 - .route("/library/tabs/collections", get(landing::library_tab_collections))
45 - .route("/library/tabs/contacts", get(landing::library_tab_contacts))
46 - .route("/library/tabs/communities", get(landing::library_tab_communities))
47 - .route("/health", get(health::health))
48 - .route("/api/health", get(health::health_json))
38 + CsrfRouter::new()
39 + .route_get("/", get(landing::index))
40 + .route_get("/library", get(landing::library))
41 + .route_get("/cart", get(landing::cart_page))
42 + .route_get("/library/tabs/purchases", get(landing::library_tab_purchases))
43 + .route_get("/library/tabs/feed", get(landing::library_tab_feed))
44 + .route_get("/library/tabs/collections", get(landing::library_tab_collections))
45 + .route_get("/library/tabs/contacts", get(landing::library_tab_contacts))
46 + .route_get("/library/tabs/communities", get(landing::library_tab_communities))
47 + .route_get("/health", get(health::health))
48 + .route_get("/api/health", get(health::health_json))
49 49 // NOTE: GET /login is registered in auth_routes() alongside POST /login
50 50 // to avoid Axum merge conflicts that strip rate limiting layers.
51 51 // Join wizard
52 - .route("/join", get(join_wizard::wizard_page))
52 + .route_get("/join", get(join_wizard::wizard_page))
53 53 .route(
54 54 "/join/step/account",
55 - axum::routing::post(join_wizard::step_account_create)
55 + post_csrf_skip(
56 + "join-wizard step 1: pre-auth signup",
57 + join_wizard::step_account_create,
58 + )
56 59 .layer(GovernorLayer { config: join_rate_limit }),
57 60 )
58 61 .route(
59 62 "/join/step/{step}",
60 - get(join_wizard::step_load).post(join_wizard::step_save),
63 + with_csrf_skip(
64 + "join-wizard: continuation of pre-auth flow",
65 + get(join_wizard::step_load).post(join_wizard::step_save),
66 + ),
61 67 )
62 - .route("/discover", get(discover::discover))
63 - .route("/discover/results", get(discover::discover_results))
64 - .route("/discover/suggestions", get(discover::search_suggestions_handler))
65 - .route("/discover/tags", get(discover::tag_tree))
66 - .route("/feed", get(feed::feed_page))
67 - .route("/u/{username}", get(content::user_page))
68 - .route("/c/{username}/{slug}", get(content::collection_page))
69 - .route("/p/{slug}", get(content::project_page))
70 - .route("/i/{item_id}", get(content::item_page))
71 - .route("/l/{item_id}", get(content::library_page))
72 - .route("/purchase/{item_id}", get(content::purchase_page))
73 - .route("/receipt/{transaction_id}", get(content::receipt_page))
74 - .route("/buy/{item_id}", get(content::buy_page))
75 - .route("/pricing", get(landing::pricing_page))
76 - .route("/checkout/complete", get(landing::checkout_complete))
77 - .route("/use-cases", get(landing::use_cases_page))
78 - .route("/team", get(landing::team_page))
79 - .route("/policy", get(landing::policy_page))
80 - .route("/fan-plus", get(landing::fan_plus_page))
81 - .route("/creators", get(creators_page))
82 - .route("/docs", get(docs::docs_index))
83 - .route("/docs/search.json", get(docs::docs_search_index))
84 - .route("/docs/{slug}", get(docs::doc_page))
68 + .route_get("/discover", get(discover::discover))
69 + .route_get("/discover/results", get(discover::discover_results))
70 + .route_get("/discover/suggestions", get(discover::search_suggestions_handler))
71 + .route_get("/discover/tags", get(discover::tag_tree))
72 + .route_get("/feed", get(feed::feed_page))
73 + .route_get("/u/{username}", get(content::user_page))
74 + .route_get("/c/{username}/{slug}", get(content::collection_page))
75 + .route_get("/p/{slug}", get(content::project_page))
76 + .route_get("/i/{item_id}", get(content::item_page))
77 + .route_get("/l/{item_id}", get(content::library_page))
78 + .route_get("/purchase/{item_id}", get(content::purchase_page))
79 + .route_get("/receipt/{transaction_id}", get(content::receipt_page))
80 + .route_get("/buy/{item_id}", get(content::buy_page))
81 + .route_get("/pricing", get(landing::pricing_page))
82 + .route_get("/checkout/complete", get(landing::checkout_complete))
83 + .route_get("/use-cases", get(landing::use_cases_page))
84 + .route_get("/team", get(landing::team_page))
85 + .route_get("/policy", get(landing::policy_page))
86 + .route_get("/fan-plus", get(landing::fan_plus_page))
87 + .route_get("/creators", get(creators_page))
88 + .route_get("/docs", get(docs::docs_index))
89 + .route_get("/docs/search.json", get(docs::docs_search_index))
90 + .route_get("/docs/{slug}", get(docs::doc_page))
85 91 // Two-factor authentication
86 - .route("/auth/2fa", get(two_factor::two_factor_page))
92 + .route_get("/auth/2fa", get(two_factor::two_factor_page))
87 93 .route(
88 94 "/auth/verify-2fa",
89 - axum::routing::post(two_factor::verify_two_factor)
95 + post_csrf_skip(
96 + "2FA verification: pre-promotion to full auth, no session yet",
97 + two_factor::verify_two_factor,
98 + )
90 99 .layer(GovernorLayer { config: twofa_rate_limit }),
91 100 )
92 101 }
@@ -7,12 +7,14 @@ use axum::{
7 7 extract::State,
8 8 http::{HeaderMap, StatusCode},
9 9 response::IntoResponse,
10 - routing::post,
11 - Json, Router,
10 + Json,
12 11 };
13 12 use serde::Deserialize;
14 13
15 - use crate::{db, AppState};
14 + use crate::{
15 + csrf::{post_csrf_skip, CsrfRouter},
16 + db, AppState,
17 + };
16 18
17 19 /// Subset of a Postmark webhook payload we care about.
18 20 #[derive(Debug, Deserialize)]
@@ -132,9 +134,9 @@ async fn postmark_webhook(
132 134 }
133 135
134 136 /// Register Postmark webhook routes.
135 - pub fn postmark_routes() -> Router<AppState> {
136 - Router::new()
137 - .route("/postmark/webhook", post(postmark_webhook))
138 - .route("/postmark/inbound", post(patches::postmark_inbound))
139 - .route("/postmark/inbound-issues", post(issues::postmark_inbound_issues))
137 + pub fn postmark_routes() -> CsrfRouter<AppState> {
138 + CsrfRouter::new()
139 + .route("/postmark/webhook", post_csrf_skip("webhook: postmark signature verified in handler", postmark_webhook))
140 + .route("/postmark/inbound", post_csrf_skip("webhook: postmark inbound, signature verified in handler", patches::postmark_inbound))
141 + .route("/postmark/inbound-issues", post_csrf_skip("webhook: postmark inbound, signature verified in handler", issues::postmark_inbound_issues))
140 142 }
@@ -6,13 +6,14 @@ pub(crate) mod media;
6 6 mod uploads;
7 7 mod versions;
8 8
9 - use axum::{routing::{delete, get, post}, Router};
9 + use axum::routing::get;
10 10 use serde::Serialize;
11 11 use tower_governor::GovernorLayer;
12 12 use uuid::Uuid;
13 13
14 14 use crate::{
15 15 constants,
16 + csrf::{delete_csrf, post_csrf, CsrfRouter},
16 17 db,
17 18 db::scan_jobs::ScanTargetKind,
18 19 error::Result,
@@ -20,36 +21,55 @@ use crate::{
20 21 AppState,
21 22 };
22 23
24 + /// Enqueue an orphaned S3 key for the pending-deletion worker.
25 + ///
26 + /// Single S3-orphan policy: every error/race path in the storage handlers
27 + /// routes its cleanup through `pending_s3_deletions` rather than calling
28 + /// `delete_object().await.ok()` inline. The queue worker is the only path
29 + /// that actually deletes — failures persist as queue rows with attempt counts
30 + /// instead of disappearing into a swallowed `Result`.
31 + pub(crate) async fn enqueue_s3_orphan(pool: &sqlx::PgPool, s3_key: &str, source: &'static str) {
32 + if let Err(e) = db::pending_s3_deletions::enqueue_deletions(
33 + pool,
34 + &[(s3_key.to_string(), "main".to_string())],
35 + source,
36 + )
37 + .await
38 + {
39 + tracing::warn!(error = ?e, key = %s3_key, source = %source, "failed to enqueue orphan S3 key");
40 + }
41 + }
42 +
23 43 /// Register S3 upload and streaming routes.
24 44 ///
25 45 /// Upload routes (presign + confirm) are rate limited per IP (see `constants::UPLOAD_RATE_LIMIT_*`).
26 46 /// Stream/download endpoints are unlimited (presigned URLs already expire in 1 hour).
27 - pub fn storage_routes() -> Router<AppState> {
47 + pub fn storage_routes() -> CsrfRouter<AppState> {
28 48 let upload_rate_limit = crate::helpers::rate_limiter_ms(constants::UPLOAD_RATE_LIMIT_MS, constants::UPLOAD_RATE_LIMIT_BURST);
29 49
30 - let upload_routes = Router::new()
31 - .route("/api/upload/presign", post(uploads::presign_upload))
32 - .route("/api/upload/confirm", post(uploads::confirm_upload))
33 - .route("/api/versions/{version_id}/upload/presign", post(versions::version_presign_upload))
34 - .route("/api/versions/{version_id}/upload/confirm", post(versions::version_confirm_upload))
35 - .route("/api/projects/image/presign", post(images::project_image_presign))
36 - .route("/api/projects/image/confirm", post(images::project_image_confirm))
37 - .route("/api/items/image/presign", post(images::item_image_presign))
38 - .route("/api/items/image/confirm", post(images::item_image_confirm))
39 - .route("/api/media/presign", post(media::media_presign))
40 - .route("/api/media/confirm", post(media::media_confirm))
41 - .route("/api/media", get(media::media_list))
42 - .route("/api/media/folders", get(media::media_folders))
43 - .route("/api/media/{id}", delete(media::media_delete))
50 + let upload_routes = CsrfRouter::new()
51 + .route("/api/upload/presign", post_csrf(uploads::presign_upload))
52 + .route("/api/upload/confirm", post_csrf(uploads::confirm_upload))
53 + .route("/api/versions/{version_id}/upload/presign", post_csrf(versions::version_presign_upload))
54 + .route("/api/versions/{version_id}/upload/confirm", post_csrf(versions::version_confirm_upload))
55 + .route("/api/projects/image/presign", post_csrf(images::project_image_presign))
56 + .route("/api/projects/image/confirm", post_csrf(images::project_image_confirm))
57 + .route("/api/items/image/presign", post_csrf(images::item_image_presign))
58 + .route("/api/items/image/confirm", post_csrf(images::item_image_confirm))
59 + .route("/api/media/presign", post_csrf(media::media_presign))
60 + .route("/api/media/confirm", post_csrf(media::media_confirm))
61 + .route_get("/api/media", get(media::media_list))
62 + .route_get("/api/media/folders", get(media::media_folders))
63 + .route("/api/media/{id}", delete_csrf(media::media_delete))
44 64 .route_layer(GovernorLayer {
45 65 config: upload_rate_limit,
46 66 });
47 67
48 68 let stream_rate_limit = crate::helpers::rate_limiter_ms(constants::STREAM_RATE_LIMIT_MS, constants::STREAM_RATE_LIMIT_BURST);
49 69
50 - let stream_routes = Router::new()
51 - .route("/api/stream/{item_id}", get(downloads::stream_url))
52 - .route("/api/versions/{version_id}/download", get(downloads::version_download))
70 + let stream_routes = CsrfRouter::new()
71 + .route_get("/api/stream/{item_id}", get(downloads::stream_url))
72 + .route_get("/api/versions/{version_id}/download", get(downloads::version_download))
53 73 .route_layer(GovernorLayer {
54 74 config: stream_rate_limit,
55 75 });
@@ -2,13 +2,16 @@
2 2
3 3 use axum::{
4 4 extract::{Path, State},
5 + http::HeaderMap,
5 6 response::{IntoResponse, Redirect},
6 7 Form,
7 8 };
8 9 use serde::Deserialize;
10 + use tower_sessions::Session;
9 11
10 12 use crate::{
11 13 auth::AuthUser,
14 + csrf,
12 15 db::{self, Cents},
13 16 error::{AppError, Result},
14 17 payments,
@@ -24,6 +27,13 @@ pub(in crate::routes::stripe) struct TipForm {
24 27 pub message: Option<String>,
25 28 /// Project ID if tipping from a project page.
26 29 pub project_id: Option<String>,
30 + /// CSRF token. The `/stripe/checkout` prefix is broadly exempt because
31 + /// most routes there only construct a Stripe session URL (state lives
32 + /// post-webhook), but this tip route inserts a `pending_tip` row BEFORE
33 + /// the Stripe call, so it gets the explicit check the rest of the
34 + /// family doesn't. The tip form already renders this field.
35 + #[serde(rename = "_csrf")]
36 + pub csrf: Option<String>,
27 37 }
28 38
29 39 /// POST /stripe/checkout/tip/{recipient_id} - Create a tip checkout session
@@ -31,9 +41,21 @@ pub(in crate::routes::stripe) struct TipForm {
31 41 pub(in crate::routes::stripe) async fn create_tip_checkout(
32 42 State(state): State<AppState>,
33 43 AuthUser(user): AuthUser,
44 + session: Session,
45 + headers: HeaderMap,
34 46 Path(recipient_id): Path<String>,
35 47 Form(form): Form<TipForm>,
36 48 ) -> Result<impl IntoResponse> {
49 + // Registered with `post_csrf_manual` because this handler inserts a
50 + // `pending_tip` row before the Stripe call — the broad `/stripe/checkout`
51 + // skip would let an attacker plant rows. Match the standard validator's
52 + // header-then-form precedence so HTMX callers and vanilla form posts
53 + // both pass. `validate_token_consuming` returns the sealed witness on
54 + // success; the binding is `_` because the witness exists only to prove
55 + // the check happened, not to be passed downstream.
56 + let token = csrf::extract_token_from_request(&headers, form.csrf.as_deref())
57 + .unwrap_or_default();
58 + let _validated = csrf::validate_token_consuming(&session, &token).await?;
37 59 user.check_not_sandbox()?;
38 60 user.check_not_suspended()?;
39 61 let stripe = state.stripe.as_ref()
@@ -10,37 +10,50 @@ mod webhook_v2;
10 10 pub(crate) use checkout::grant_bundle_items;
11 11 pub(crate) use webhook::process_webhook_event;
12 12
13 - use axum::{
14 - routing::{get, post},
15 - Router,
13 + use axum::routing::get;
14 +
15 + use crate::{
16 + csrf::{post_csrf, post_csrf_manual, post_csrf_skip, CsrfRouter},
17 + AppState,
16 18 };
17 19
18 - use crate::AppState;
20 + /// Reason string for routes that only construct a Stripe Checkout Session URL.
21 + /// State mutation happens server-side via the Stripe webhook, not in these
22 + /// handlers. The `tip` route is the documented exception — it inserts a
23 + /// `pending_tip` row BEFORE the Stripe call, so it uses Manual posture.
24 + const STRIPE_SESSION_SKIP: &str =
25 + "Stripe Checkout Session constructor — no mutation until webhook";
19 26
20 27 /// Register Stripe Connect, Checkout, and webhook routes.
21 - pub fn stripe_routes() -> Router<AppState> {
22 - Router::new()
28 + pub fn stripe_routes() -> CsrfRouter<AppState> {
29 + CsrfRouter::new()
23 30 // Creator onboarding (Account Links flow)
24 - .route("/stripe/connect", get(connect::stripe_connect_disclaimer))
25 - .route("/stripe/connect/proceed", post(connect::stripe_connect_proceed))
26 - .route("/stripe/connect/return", get(connect::stripe_connect_return))
27 - .route("/stripe/connect/refresh", get(connect::stripe_connect_refresh))
31 + .route_get("/stripe/connect", get(connect::stripe_connect_disclaimer))
32 + .route("/stripe/connect/proceed", post_csrf(connect::stripe_connect_proceed))
33 + .route_get("/stripe/connect/return", get(connect::stripe_connect_return))
34 + .route_get("/stripe/connect/refresh", get(connect::stripe_connect_refresh))
28 35 // Checkout flow (idempotency handled by global middleware in metrics.rs)
29 - .route("/stripe/fan-plus", post(checkout::create_fan_plus_checkout))
30 - .route("/stripe/fan-plus/cancel", post(checkout::cancel_fan_plus))
31 - .route("/stripe/fan-plus/resume", post(checkout::resume_fan_plus))
32 - .route("/stripe/billing-portal", post(checkout::open_billing_portal))
33 - .route("/stripe/creator-tier", post(checkout::create_creator_tier_checkout))
34 - .route("/stripe/checkout/{item_id}", post(checkout::create_checkout))
35 - .route("/stripe/checkout/{item_id}/cancel-pending", post(checkout::cancel_pending_item_checkout))
36 - .route("/stripe/checkout/project/{project_id}", post(checkout::create_project_checkout))
37 - .route("/stripe/subscribe/{tier_id}", post(checkout::create_subscription_checkout))
38 - .route("/stripe/checkout/tip/{recipient_id}", post(checkout::create_tip_checkout))
39 - .route("/stripe/checkout/cart", post(checkout::create_cart_checkout))
40 - .route("/stripe/checkout/cart/all", post(checkout::create_cart_checkout_all))
41 - .route("/stripe/success", get(checkout::checkout_success))
42 - .route("/stripe/cancel", get(checkout::checkout_cancel))
36 + .route("/stripe/fan-plus", post_csrf(checkout::create_fan_plus_checkout))
37 + .route("/stripe/fan-plus/cancel", post_csrf(checkout::cancel_fan_plus))
38 + .route("/stripe/fan-plus/resume", post_csrf(checkout::resume_fan_plus))
39 + .route("/stripe/billing-portal", post_csrf(checkout::open_billing_portal))
40 + .route("/stripe/creator-tier", post_csrf(checkout::create_creator_tier_checkout))
41 + .route("/stripe/checkout/{item_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_checkout))
42 + // cancel-pending was in the old allowlist; behavior-preserving Skip.
43 + // The Phase 1 entry "cancel_pending_item_checkout CSRF gap" tracks
44 + // moving this to `post_csrf` once the form renders the token.
45 + .route("/stripe/checkout/{item_id}/cancel-pending", post_csrf_skip("Phase 1 todo: tighten to post_csrf", checkout::cancel_pending_item_checkout))
46 + .route("/stripe/checkout/project/{project_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_project_checkout))
47 + .route("/stripe/subscribe/{tier_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_subscription_checkout))
48 + .route("/stripe/checkout/tip/{recipient_id}", post_csrf_manual(
49 + "inserts pending_tip row before Stripe call — handler validates _csrf",
50 + checkout::create_tip_checkout,
51 + ))
52 + .route("/stripe/checkout/cart", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_cart_checkout))
53 + .route("/stripe/checkout/cart/all", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_cart_checkout_all))
54 + .route_get("/stripe/success", get(checkout::checkout_success))
55 + .route_get("/stripe/cancel", get(checkout::checkout_cancel))
43 56 // Webhooks
44 - .route("/stripe/webhook", post(webhook::webhook))
45 - .route("/stripe/webhook/v2", post(webhook_v2::webhook_v2))
57 + .route("/stripe/webhook", post_csrf_skip("webhook: stripe signature verified in handler", webhook::webhook))
58 + .route("/stripe/webhook/v2", post_csrf_skip("webhook: stripe signature verified in handler", webhook_v2::webhook_v2))
46 59 }
@@ -22,20 +22,25 @@ pub(crate) mod keys;
22 22 mod subscribe;
23 23 pub(crate) mod sync;
24 24
25 - use axum::{
26 - routing::{delete, get, post, put},
27 - Router,
28 - };
25 + use axum::routing::get;
29 26 use chrono::{DateTime, Utc};
30 27 use serde::{Deserialize, Serialize};
31 28 use tower_governor::GovernorLayer;
32 29
33 30 use crate::{
34 31 constants,
32 + csrf::{delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf, put_csrf_skip, CsrfRouter},
35 33 db::{self, SyncAppId, SyncDeviceId, SyncOperation, SyncPlatform, UserId},
36 34 AppState,
37 35 };
38 36
37 + /// Reason strings for synckit CSRF Skip routes. The auth_routes and
38 + /// sync_routes blocks use server-to-server or JWT bearer auth with no
39 + /// session cookie; CSRF doesn't apply. The app_routes block IS
40 + /// session-authed (dashboard-driven) so those use `post_csrf` etc.
41 + const SYNCKIT_API_KEY_SKIP: &str = "synckit server-to-server: api_key auth, no session";
42 + const SYNCKIT_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session";
43 +
39 44 // ── Request/Response types ──
40 45
41 46 #[derive(Deserialize, utoipa::ToSchema)]
@@ -525,23 +530,23 @@ pub(super) fn generate_api_key() -> String {
525 530 /// - **App management routes** (`/api/sync/apps/...`): Session-based auth
526 531 /// via `AuthUser` extractor (accessed from the MNW dashboard), no extra
527 532 /// rate limit beyond the global middleware.
528 - pub fn synckit_routes() -> Router<AppState> {
533 + pub fn synckit_routes() -> CsrfRouter<AppState> {
529 534 let auth_rate_limit = crate::helpers::rate_limiter_per_sec(constants::SYNCKIT_AUTH_RATE_LIMIT_PER_SEC, constants::SYNCKIT_AUTH_RATE_LIMIT_BURST);
530 535
531 - let auth_routes = Router::new()
532 - .route("/api/sync/auth", post(auth::sync_auth))
533 - .route("/api/v1/sync/auth", post(auth::sync_auth))
534 - .route("/api/sync/validate-app", post(auth::validate_app))
535 - .route("/api/v1/sync/validate-app", post(auth::validate_app))
536 + let auth_routes = CsrfRouter::new()
537 + .route("/api/sync/auth", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth))
538 + .route("/api/v1/sync/auth", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth))
539 + .route("/api/sync/validate-app", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app))
540 + .route("/api/v1/sync/validate-app", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app))
536 541 // Server-to-server SDK key claim/release/list (api_key in body, no JWT).
537 - .route("/api/sync/keys/claim", post(keys::claim))
538 - .route("/api/v1/sync/keys/claim", post(keys::claim))
539 - .route("/api/sync/keys/release", post(keys::release))
540 - .route("/api/v1/sync/keys/release", post(keys::release))
541 - .route("/api/sync/keys/list", post(keys::list))
542 - .route("/api/v1/sync/keys/list", post(keys::list))
543 - .route("/api/sync/app/pricing", post(sync::get_app_pricing))
544 - .route("/api/v1/sync/app/pricing", post(sync::get_app_pricing))
542 + .route("/api/sync/keys/claim", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::claim))
543 + .route("/api/v1/sync/keys/claim", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::claim))
544 + .route("/api/sync/keys/release", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::release))
545 + .route("/api/v1/sync/keys/release", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::release))
546 + .route("/api/sync/keys/list", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::list))
547 + .route("/api/v1/sync/keys/list", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::list))
548 + .route("/api/sync/app/pricing", post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing))
549 + .route("/api/v1/sync/app/pricing", post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing))
545 550 .route_layer(GovernorLayer {
546 551 config: auth_rate_limit,
547 552 });
@@ -549,51 +554,51 @@ pub fn synckit_routes() -> Router<AppState> {
549 554 let sync_ip_rate_limit = crate::helpers::rate_limiter_ms(constants::SYNCKIT_SYNC_RATE_LIMIT_MS, constants::SYNCKIT_SYNC_RATE_LIMIT_BURST);
550 555 let sync_app_rate_limit = crate::helpers::synckit_app_rate_limiter_ms(constants::SYNCKIT_APP_RATE_LIMIT_MS, constants::SYNCKIT_APP_RATE_LIMIT_BURST);
551 556
552 - let sync_routes = Router::new()
553 - .route("/api/sync/push", post(sync::sync_push))
554 - .route("/api/v1/sync/push", post(sync::sync_push))
555 - .route("/api/sync/pull", post(sync::sync_pull))
556 - .route("/api/v1/sync/pull", post(sync::sync_pull))
557 - .route("/api/sync/subscribe", get(subscribe::sync_subscribe))
558 - .route("/api/v1/sync/subscribe", get(subscribe::sync_subscribe))
559 - .route("/api/sync/status", get(sync::sync_status))
560 - .route("/api/v1/sync/status", get(sync::sync_status))
561 - .route("/api/sync/account", get(sync::sync_account))
562 - .route("/api/v1/sync/account", get(sync::sync_account))
563 - .route("/api/sync/subscription", get(sync::sync_subscription_status))
564 - .route("/api/v1/sync/subscription", get(sync::sync_subscription_status))
565 - .route("/api/sync/subscription/quote", post(sync::quote_subscription_price))
566 - .route("/api/v1/sync/subscription/quote", post(sync::quote_subscription_price))
567 - .route("/api/sync/subscription/checkout", post(sync::create_subscription_checkout))
568 - .route("/api/v1/sync/subscription/checkout", post(sync::create_subscription_checkout))
569 - .route("/api/sync/subscription/storage-cap", post(sync::queue_storage_cap_change))
570 - .route("/api/v1/sync/subscription/storage-cap", post(sync::queue_storage_cap_change))
571 - .route("/api/sync/devices", post(sync::register_device))
572 - .route("/api/v1/sync/devices", post(sync::register_device))
573 - .route("/api/sync/devices", get(sync::list_devices))
574 - .route("/api/v1/sync/devices", get(sync::list_devices))
575 - .route("/api/sync/devices/{id}", delete(sync::delete_device))
576 - .route("/api/v1/sync/devices/{id}", delete(sync::delete_device))
577 - .route("/api/sync/keys", put(sync::put_sync_key))
578 - .route("/api/v1/sync/keys", put(sync::put_sync_key))
579 - .route("/api/sync/keys", get(sync::get_sync_key))
580 - .route("/api/v1/sync/keys", get(sync::get_sync_key))
581 - .route("/api/sync/keys/rotate", post(sync::begin_rotation))
582 - .route("/api/v1/sync/keys/rotate", post(sync::begin_rotation))
583 - .route("/api/sync/keys/rotate", delete(sync::cancel_rotation))
584 - .route("/api/v1/sync/keys/rotate", delete(sync::cancel_rotation))
585 - .route("/api/sync/keys/rotate/entries", post(sync::rotation_entries))
586 - .route("/api/v1/sync/keys/rotate/entries", post(sync::rotation_entries))
587 - .route("/api/sync/keys/rotate/batch", post(sync::rotation_batch))
588 - .route("/api/v1/sync/keys/rotate/batch", post(sync::rotation_batch))
589 - .route("/api/sync/keys/rotate/complete", post(sync::complete_rotation))
590 - .route("/api/v1/sync/keys/rotate/complete", post(sync::complete_rotation))
591 - .route("/api/sync/blobs/upload", post(blobs::blob_upload_url))
592 - .route("/api/v1/sync/blobs/upload", post(blobs::blob_upload_url))
593 - .route("/api/sync/blobs/confirm", post(blobs::blob_confirm_upload))
594 - .route("/api/v1/sync/blobs/confirm", post(blobs::blob_confirm_upload))
595 - .route("/api/sync/blobs/download", post(blobs::blob_download_url))
596 - .route("/api/v1/sync/blobs/download", post(blobs::blob_download_url))
557 + let sync_routes = CsrfRouter::new()
558 + .route("/api/sync/push", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push))
559 + .route("/api/v1/sync/push", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push))
560 + .route("/api/sync/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull))
561 + .route("/api/v1/sync/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull))
562 + .route_get("/api/sync/subscribe", get(subscribe::sync_subscribe))
563 + .route_get("/api/v1/sync/subscribe", get(subscribe::sync_subscribe))
564 + .route_get("/api/sync/status", get(sync::sync_status))
565 + .route_get("/api/v1/sync/status", get(sync::sync_status))
566 + .route_get("/api/sync/account", get(sync::sync_account))
567 + .route_get("/api/v1/sync/account", get(sync::sync_account))
568 + .route_get("/api/sync/subscription", get(sync::sync_subscription_status))
569 + .route_get("/api/v1/sync/subscription", get(sync::sync_subscription_status))
570 + .route("/api/sync/subscription/quote", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price))
571 + .route("/api/v1/sync/subscription/quote", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price))
572 + .route("/api/sync/subscription/checkout", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout))
573 + .route("/api/v1/sync/subscription/checkout", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout))
574 + .route("/api/sync/subscription/storage-cap", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change))
575 + .route("/api/v1/sync/subscription/storage-cap", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change))
576 + .route("/api/sync/devices", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device))
577 + .route("/api/v1/sync/devices", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device))
578 + .route_get("/api/sync/devices", get(sync::list_devices))
579 + .route_get("/api/v1/sync/devices", get(sync::list_devices))
580 + .route("/api/sync/devices/{id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device))
581 + .route("/api/v1/sync/devices/{id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device))
582 + .route("/api/sync/keys", put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key))
583 + .route("/api/v1/sync/keys", put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key))
584 + .route_get("/api/sync/keys", get(sync::get_sync_key))
585 + .route_get("/api/v1/sync/keys", get(sync::get_sync_key))
586 + .route("/api/sync/keys/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation))
587 + .route("/api/v1/sync/keys/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation))
588 + .route("/api/sync/keys/rotate", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation))
589 + .route("/api/v1/sync/keys/rotate", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation))
590 + .route("/api/sync/keys/rotate/entries", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries))
591 + .route("/api/v1/sync/keys/rotate/entries", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries))
592 + .route("/api/sync/keys/rotate/batch", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch))
593 + .route("/api/v1/sync/keys/rotate/batch", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch))
594 + .route("/api/sync/keys/rotate/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation))
595 + .route("/api/v1/sync/keys/rotate/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation))
596 + .route("/api/sync/blobs/upload", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url))
597 + .route("/api/v1/sync/blobs/upload", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url))
598 + .route("/api/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload))
599 + .route("/api/v1/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload))
600 + .route("/api/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url))
601 + .route("/api/v1/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url))
597 602 // Per-app rate limit (inner layer runs first): prevents one developer's
598 603 // app from starving other apps. Extracts app ID from JWT payload.
599 604 .route_layer(GovernorLayer {
@@ -606,32 +611,32 @@ pub fn synckit_routes() -> Router<AppState> {
606 611 });
607 612
608 613 // App management endpoints use session auth (no extra rate limit beyond global)
609 - let app_routes = Router::new()
610 - .route("/api/sync/apps", post(apps::create_app))
611 - .route("/api/v1/sync/apps", post(apps::create_app))
612 - .route("/api/sync/apps", get(apps::list_apps))
613 - .route("/api/v1/sync/apps", get(apps::list_apps))
614 - .route("/api/sync/apps/{id}/regenerate-key", post(apps::regenerate_app_key))
615 - .route("/api/v1/sync/apps/{id}/regenerate-key", post(apps::regenerate_app_key))
616 - .route("/api/sync/apps/{id}/link", put(apps::update_app_link))
617 - .route("/api/v1/sync/apps/{id}/link", put(apps::update_app_link))
618 - .route("/api/sync/apps/{id}/slug", put(apps::update_app_slug))
619 - .route("/api/v1/sync/apps/{id}/slug", put(apps::update_app_slug))
620 - .route("/api/sync/apps/{id}", delete(apps::delete_app))
621 - .route("/api/v1/sync/apps/{id}", delete(apps::delete_app))
614 + let app_routes = CsrfRouter::new()
615 + .route("/api/sync/apps", post_csrf(apps::create_app))
616 + .route("/api/v1/sync/apps", post_csrf(apps::create_app))
617 + .route_get("/api/sync/apps", get(apps::list_apps))
618 + .route_get("/api/v1/sync/apps", get(apps::list_apps))
619 + .route("/api/sync/apps/{id}/regenerate-key", post_csrf(apps::regenerate_app_key))
620 + .route("/api/v1/sync/apps/{id}/regenerate-key", post_csrf(apps::regenerate_app_key))
621 + .route("/api/sync/apps/{id}/link", put_csrf(apps::update_app_link))
622 + .route("/api/v1/sync/apps/{id}/link", put_csrf(apps::update_app_link))
623 + .route("/api/sync/apps/{id}/slug", put_csrf(apps::update_app_slug))
624 + .route("/api/v1/sync/apps/{id}/slug", put_csrf(apps::update_app_slug))
625 + .route("/api/sync/apps/{id}", delete_csrf(apps::delete_app))
626 + .route("/api/v1/sync/apps/{id}", delete_csrf(apps::delete_app))
622 627 // Developer billing (session auth, dashboard-driven).
623 - .route("/api/sync/apps/{id}/billing/setup", post(billing::setup))
624 - .route("/api/v1/sync/apps/{id}/billing/setup", post(billing::setup))
625 - .route("/api/sync/apps/{id}/billing/activate", post(billing::activate))
626 - .route("/api/v1/sync/apps/{id}/billing/activate", post(billing::activate))
627 - .route("/api/sync/apps/{id}/billing", axum::routing::patch(billing::patch))
628 - .route("/api/v1/sync/apps/{id}/billing", axum::routing::patch(billing::patch))
629 - .route("/api/sync/apps/{id}/billing", delete(billing::cancel))
630 - .route("/api/v1/sync/apps/{id}/billing", delete(billing::cancel))
631 - .route("/api/sync/apps/{id}/billing", get(billing::get))
632 - .route("/api/v1/sync/apps/{id}/billing", get(billing::get))
633 - .route("/api/sync/apps/{id}/billing/portal", get(billing::portal))
634 - .route("/api/v1/sync/apps/{id}/billing/portal", get(billing::portal));
628 + .route("/api/sync/apps/{id}/billing/setup", post_csrf(billing::setup))
629 + .route("/api/v1/sync/apps/{id}/billing/setup", post_csrf(billing::setup))
630 + .route("/api/sync/apps/{id}/billing/activate", post_csrf(billing::activate))
631 + .route("/api/v1/sync/apps/{id}/billing/activate", post_csrf(billing::activate))
632 + .route("/api/sync/apps/{id}/billing", patch_csrf(billing::patch))
633 + .route("/api/v1/sync/apps/{id}/billing", patch_csrf(billing::patch))
634 + .route("/api/sync/apps/{id}/billing", delete_csrf(billing::cancel))
635 + .route("/api/v1/sync/apps/{id}/billing", delete_csrf(billing::cancel))
636 + .route_get("/api/sync/apps/{id}/billing", get(billing::get))
637 + .route_get("/api/v1/sync/apps/{id}/billing", get(billing::get))
638 + .route_get("/api/sync/apps/{id}/billing/portal", get(billing::portal))
639 + .route_get("/api/v1/sync/apps/{id}/billing/portal", get(billing::portal));
635 640
636 641 auth_routes.merge(sync_routes).merge(app_routes)
637 642 }
@@ -229,6 +229,9 @@ impl TestHarness {
229 229 clamav_socket: None,
230 230 yara_rules_dir: "/nonexistent".to_string(),
231 231 malwarebazaar_enabled: false,
232 + urlhaus_enabled: false,
233 + abuse_ch_auth_key: None,
234 + metadefender_api_key: None,
232 235 };
233 236 ScanPipeline::new(&scan_config).expect("ScanPipeline::new with no-op config")
234 237 }