Skip to main content

cratestack_axum/idempotency/
layer.rs

1//! Tower layer + companion `Service` constructor.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use axum::extract::{ConnectInfo, Request};
8use http::header;
9use sha2::{Digest, Sha256};
10use tower::Layer;
11
12use super::service::IdempotencyService;
13use super::store::IdempotencyStore;
14
15/// Tower layer that wires an `IdempotencyStore` into the request pipeline.
16#[derive(Clone)]
17pub struct IdempotencyLayer {
18    pub(super) store: Arc<dyn IdempotencyStore>,
19    pub(super) ttl: Duration,
20    pub(super) principal_fingerprint: Arc<dyn Fn(&Request) -> String + Send + Sync>,
21}
22
23impl IdempotencyLayer {
24    /// Construct with a default principal fingerprint derived from the
25    /// `Authorization` header, falling back to the verified TCP peer address
26    /// (via axum's `ConnectInfo<SocketAddr>`, requires serving through
27    /// `into_make_service_with_connect_info::<SocketAddr>()`) when it's
28    /// absent. Callers running mTLS or session-cookie auth should swap this
29    /// via [`with_principal_fingerprint`].
30    pub fn new(store: Arc<dyn IdempotencyStore>, ttl: Duration) -> Self {
31        Self {
32            store,
33            ttl,
34            principal_fingerprint: Arc::new(default_principal_fingerprint),
35        }
36    }
37
38    /// Override how the layer derives a principal-scoped namespace for the
39    /// idempotency key. Without this, two callers sharing a key (across
40    /// tenants) would collide.
41    pub fn with_principal_fingerprint(
42        mut self,
43        f: impl Fn(&Request) -> String + Send + Sync + 'static,
44    ) -> Self {
45        self.principal_fingerprint = Arc::new(f);
46        self
47    }
48}
49
50pub(super) fn default_principal_fingerprint(req: &Request) -> String {
51    // Prefer Authorization header for authenticated requests.
52    if let Some(auth_header) = req.headers().get(header::AUTHORIZATION)
53        && let Ok(auth_str) = auth_header.to_str()
54    {
55        let mut h = Sha256::new();
56        h.update(auth_str.as_bytes());
57        return format!("{:x}", h.finalize());
58    }
59
60    // Fall back to the real TCP peer address for unauthenticated requests, to
61    // avoid collisions between distinct callers. This is deliberately *not*
62    // `Forwarded`/`X-Forwarded-For`: those headers are client-supplied and
63    // this crate has no trusted-proxy configuration to verify or strip them,
64    // so trusting them here would let an attacker land in another caller's
65    // idempotency namespace just by guessing/spoofing that caller's apparent
66    // IP. `ConnectInfo` is populated by axum from the actual accepted socket
67    // (when the server is served via `into_make_service_with_connect_info::<SocketAddr>()`)
68    // and cannot be spoofed by the client.
69    if let Some(ConnectInfo(addr)) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
70        return addr.ip().to_string();
71    }
72
73    // Only if both Authorization and a verified peer address are absent
74    // (e.g. the server isn't wired through `into_make_service_with_connect_info`),
75    // fall back to a single shared namespace. This matches the pre-existing,
76    // safe-by-default behavior: unauthenticated traffic that can't be
77    // distinguished falls back to the coarse default rather than trusting an
78    // unverifiable, attacker-controlled key.
79    "anonymous".to_owned()
80}
81
82impl<S> Layer<S> for IdempotencyLayer {
83    type Service = IdempotencyService<S>;
84
85    fn layer(&self, inner: S) -> Self::Service {
86        IdempotencyService {
87            inner,
88            store: self.store.clone(),
89            ttl: self.ttl,
90            principal_fingerprint: self.principal_fingerprint.clone(),
91        }
92    }
93}