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, +986 insertions, -618 deletions
M server/src/csrf.rs +301 -40
@@ -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 @@
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();
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 + }
140 153
141 - // Only validate state-changing methods
142 - if !["POST", "PUT", "PATCH", "DELETE"].contains(&method.as_str()) {
143 - return next.run(request).await;
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();
166 + pub(super) fn make_validated() -> CsrfManuallyValidated {
167 + CsrfManuallyValidated { _private: () }
168 + }
169 + }
147 170
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 - ];
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 + }
168 186
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;
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())
174 356 }
175 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))
370 + }
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 @@
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
@@ -438,6 +684,21 @@
438 684 assert!(!constant_time_compare(&token, &tampered));
439 685 }
440 686
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 +
441 702 #[test]
442 703 fn test_constant_time_compare_truncated() {
443 704 use crate::helpers::constant_time_compare;
@@ -127,8 +127,12 @@
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 @@
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 @@
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))
@@ -6,7 +6,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 });
@@ -8,9 +8,10 @@
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 @@
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 @@
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 @@
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 @@
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 )
@@ -229,6 +229,9 @@
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 }