feat(DPoP): add verification + X-Forwarded-Host X-Forwarded-Proto
This commit is contained in:
Generated
+1
@@ -4490,6 +4490,7 @@ dependencies = [
|
||||
"nom-exif",
|
||||
"opaque-ke",
|
||||
"ort",
|
||||
"p256",
|
||||
"pdf-extract",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.41.0",
|
||||
|
||||
@@ -63,6 +63,10 @@ rand_core = { version = "0.6", features = ["std", "getrandom"] }
|
||||
# changing it invalidates every user's registration record, plan a
|
||||
# migration before touching. `argon2` feature gates the memory-hard KSF.
|
||||
opaque-ke = { version = "3", features = ["argon2"] }
|
||||
# DPoP (RFC 9449) proof verification — ECDSA P-256 signatures. Transitive
|
||||
# via jsonwebtoken's rust_crypto feature; declared here for direct use in
|
||||
# `infrastructure::services::dpop_verifier`.
|
||||
p256 = { version = "0.13", features = ["ecdsa"] }
|
||||
quick-xml = "0.41.0"
|
||||
dotenvy = "0.15.7"
|
||||
moka = { version = "0.12.15", features = ["future", "sync"] }
|
||||
|
||||
@@ -479,11 +479,7 @@ pub trait SessionStoragePort: Send + Sync + 'static {
|
||||
/// redemption). Fails with `AlreadyExists` if the session already
|
||||
/// carries a thumbprint (anti-downgrade invariant, see
|
||||
/// `docs/plan/dpop.md`).
|
||||
async fn bind_dpop_jkt(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
dpop_jkt: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -2263,7 +2263,11 @@ impl AuthApplicationService {
|
||||
"dpop_jkt must be a 43-character base64url SHA-256 thumbprint (RFC 7638)",
|
||||
)
|
||||
})?;
|
||||
match self.session_storage.bind_dpop_jkt(session_id, &validated).await {
|
||||
match self
|
||||
.session_storage
|
||||
.bind_dpop_jkt(session_id, &validated)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
|
||||
@@ -1448,6 +1448,55 @@ pub struct AuthConfig {
|
||||
///
|
||||
/// Env: `OXICLOUD_REQUIRE_VERIFIED_EMAIL` (default `false`).
|
||||
pub require_verified_email: bool,
|
||||
|
||||
/// DPoP session-binding enforcement (RFC 9449). Bound sessions —
|
||||
/// those created with a `dpop_jkt` supplied at login — carry a
|
||||
/// browser-held keypair thumbprint; the middleware verifies a
|
||||
/// per-request signed proof so that stealing the session cookie
|
||||
/// alone is useless without the private key.
|
||||
///
|
||||
/// Modes (see `DpopMode` enum):
|
||||
/// * `Off` (default) — middleware is a pass-through; no
|
||||
/// verification even when a proof is present. Ship-safe
|
||||
/// default while the client rollout catches up.
|
||||
/// * `Opportunistic` — verify when a proof is present, reject
|
||||
/// mismatches; skip when absent. Warn on
|
||||
/// `dpop.header_missing_but_session_bound`. Rollout mode.
|
||||
/// * `Required` — bound sessions MUST present a valid proof.
|
||||
/// Unbound sessions (`dpop_jkt IS NULL` — app passwords,
|
||||
/// Nextcloud clients, legacy) remain exempt at the
|
||||
/// middleware level.
|
||||
///
|
||||
/// Env: `OXICLOUD_DPOP_MODE` in `{off,opportunistic,required}`
|
||||
/// (default `off`).
|
||||
pub dpop_mode: DpopMode,
|
||||
}
|
||||
|
||||
/// DPoP session-binding enforcement mode. See `AuthConfig::dpop_mode`
|
||||
/// and `docs/plan/dpop.md` for the rollout strategy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DpopMode {
|
||||
/// Middleware pass-through — DPoP header is neither required nor
|
||||
/// verified. Default: safe while clients roll out proof-signing.
|
||||
#[default]
|
||||
Off,
|
||||
/// Verify when present, allow when absent. Bound sessions still
|
||||
/// get a warning audit line when they arrive without a proof.
|
||||
Opportunistic,
|
||||
/// Bound sessions (`dpop_jkt IS NOT NULL`) MUST present a valid
|
||||
/// proof or 401. Unbound sessions remain exempt.
|
||||
Required,
|
||||
}
|
||||
|
||||
impl DpopMode {
|
||||
pub fn from_env_str(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"off" => Some(Self::Off),
|
||||
"opportunistic" => Some(Self::Opportunistic),
|
||||
"required" => Some(Self::Required),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-service auth method. Exposed as `AuthConfig::allowed_auth_methods`
|
||||
@@ -1583,6 +1632,7 @@ impl Default for AuthConfig {
|
||||
auth_policies: Vec::new(),
|
||||
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
||||
require_verified_email: false,
|
||||
dpop_mode: DpopMode::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2978,6 +3028,15 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(v) = env::var("OXICLOUD_DPOP_MODE") {
|
||||
match DpopMode::from_env_str(&v) {
|
||||
Some(mode) => config.auth.dpop_mode = mode,
|
||||
None => panic!(
|
||||
"OXICLOUD_DPOP_MODE={v:?} — expected one of off / opportunistic / required"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(v) = env::var("OXICLOUD_REQUIRE_VERIFIED_EMAIL") {
|
||||
config.auth.require_verified_email = v.parse::<bool>().unwrap_or(false);
|
||||
}
|
||||
|
||||
@@ -2102,6 +2102,9 @@ impl AppServiceFactory {
|
||||
opaque_service,
|
||||
opaque_repo,
|
||||
opaque_login_exchange,
|
||||
dpop_nonce_service: Arc::new(
|
||||
crate::infrastructure::services::dpop_nonce_service::DpopNonceService::new(),
|
||||
),
|
||||
nextcloud: nextcloud_services,
|
||||
admin_settings_service: None,
|
||||
storage_settings_service: None,
|
||||
@@ -2874,6 +2877,12 @@ pub struct AppState {
|
||||
pub opaque_login_exchange: Option<
|
||||
Arc<crate::infrastructure::services::opaque_login_exchange::OpaqueLoginExchange>,
|
||||
>,
|
||||
/// DPoP nonce pool. Always populated (even in `dpop_mode = off`)
|
||||
/// so switching mode via env-flip needs no restart-time wiring
|
||||
/// change. Cheap-to-construct in-memory moka cache; unused paths
|
||||
/// pay only allocation cost at boot.
|
||||
pub dpop_nonce_service:
|
||||
Arc<crate::infrastructure::services::dpop_nonce_service::DpopNonceService>,
|
||||
pub nextcloud: Option<NextcloudServices>,
|
||||
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
||||
/// WASM plugin management (list/install/toggle/remove), backing the admin
|
||||
|
||||
@@ -121,9 +121,5 @@ pub trait SessionRepository: Send + Sync + 'static {
|
||||
/// downgrade guard from `docs/plan/dpop.md` — an attacker who has
|
||||
/// stolen the cookie of a bound session cannot re-bind to their
|
||||
/// own key.
|
||||
async fn bind_dpop_jkt(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
dpop_jkt: &str,
|
||||
) -> SessionRepositoryResult<()>;
|
||||
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()>;
|
||||
}
|
||||
|
||||
@@ -469,11 +469,7 @@ impl SessionRepository for SessionPgRepository {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn bind_dpop_jkt(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
dpop_jkt: &str,
|
||||
) -> SessionRepositoryResult<()> {
|
||||
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()> {
|
||||
// `WHERE dpop_jkt IS NULL` enforces the immutability invariant
|
||||
// at the SQL level — a bound session's UPDATE affects 0 rows
|
||||
// and we surface `DpopAlreadyBound`. Also guards against a
|
||||
@@ -648,11 +644,7 @@ impl SessionStoragePort for SessionPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn bind_dpop_jkt(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
dpop_jkt: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError> {
|
||||
SessionRepository::bind_dpop_jkt(self, session_id, dpop_jkt)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//! DPoP proof verifier (RFC 9449) — pure functions over a compact JWS.
|
||||
//!
|
||||
//! Consumes a DPoP header value produced by
|
||||
//! `frontend/src/lib/auth/dpop-proof.ts` (or the `dpop-hurl-helper`
|
||||
//! test binary — Gate 6b) and returns a typed verdict.
|
||||
//!
|
||||
//! Nothing here touches the DB or the request extractor pipeline —
|
||||
//! that's the middleware's job (see `src/interfaces/middleware/dpop.rs`).
|
||||
//! Keeping the verifier pure makes the failure-mode matrix trivially
|
||||
//! unit-testable: `verify(proof, method, htu, expected_jkt, now)`.
|
||||
//!
|
||||
//! **Nonce validation is a caller responsibility for now** — the
|
||||
//! nonce claim is extracted and returned as part of the OK verdict,
|
||||
//! but the caller (middleware) validates it against the nonce
|
||||
//! service. Wiring lands in Gate 5b; at Gate 5 the middleware
|
||||
//! ignores the nonce field (opportunistic path).
|
||||
//!
|
||||
//! Ciphersuite: **ES256 ONLY** (ECDSA P-256 + SHA-256). Any other
|
||||
//! `alg` or `crv` is a hard reject — RFC 9449 §4 mandates support
|
||||
//! for ES256 and we don't accept anything looser.
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64_URL_NO_PAD;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Per-request context the verifier compares proof claims against.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DpopRequestContext<'a> {
|
||||
/// Uppercase HTTP method (e.g. `"POST"`).
|
||||
pub htm: &'a str,
|
||||
/// Canonical target URL: `scheme://authority/path` — NO query, NO
|
||||
/// fragment. Middleware builds this from the external scheme +
|
||||
/// host (`X-Forwarded-*`-aware) + `OriginalUri` path.
|
||||
pub htu: &'a str,
|
||||
/// Server clock (unix seconds). Injected so tests can pin it
|
||||
/// deterministically.
|
||||
pub now_secs: i64,
|
||||
/// Session's stored thumbprint — set at login (`session.dpop_jkt`).
|
||||
/// When present, the proof's JWK thumbprint MUST match.
|
||||
pub expected_jkt: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Successful verify outcome — the middleware may still need to
|
||||
/// validate the nonce (Gate 5b) and jti (Gate 6, replay cache).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DpopVerified {
|
||||
/// RFC 7638 JWK thumbprint of the proof's public key.
|
||||
/// Middleware compares to `session.dpop_jkt` (already done here
|
||||
/// when `expected_jkt` was set) and may audit-log this value.
|
||||
pub jkt: String,
|
||||
/// Nonce claim from the proof, if any. Bootstrap-only branch has
|
||||
/// `None` — the very first request per session precedes the
|
||||
/// server-issued nonce, so the client can't include it.
|
||||
pub nonce: Option<String>,
|
||||
/// Unique proof id — the replay cache in Gate 6 keys off this.
|
||||
pub jti: String,
|
||||
/// Claimed issue time (unix seconds) — informational when nonce
|
||||
/// is present (server clock is authoritative via nonce validity),
|
||||
/// bounded ±30s when no nonce yet (bootstrap branch).
|
||||
pub iat: i64,
|
||||
}
|
||||
|
||||
/// Machine-readable failure reasons. Stringly matched by the middleware
|
||||
/// for the audit `reason=` field — DO NOT rename variants without
|
||||
/// coordinating with dashboards.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DpopVerifyError {
|
||||
/// JWS isn't three base64url segments, or a segment fails to
|
||||
/// decode, or the JSON header/claims fail to parse.
|
||||
Malformed,
|
||||
/// `typ` header field is not `"dpop+jwt"`.
|
||||
WrongTyp,
|
||||
/// `alg` header field is not `"ES256"`.
|
||||
WrongAlg,
|
||||
/// `jwk` header member is missing or not an EC/P-256 public key.
|
||||
WrongJwk,
|
||||
/// ECDSA signature does not verify over `header.payload`.
|
||||
SignatureInvalid,
|
||||
/// `htm` claim doesn't match the request method.
|
||||
WrongHtm,
|
||||
/// `htu` claim doesn't match the canonical request URL.
|
||||
WrongHtu,
|
||||
/// `iat` claim is missing / non-numeric.
|
||||
IatMissing,
|
||||
/// `iat` claim is outside the ±30s bootstrap window (no nonce yet).
|
||||
IatOutOfWindow,
|
||||
/// `jti` claim is missing / empty.
|
||||
JtiMissing,
|
||||
/// Proof's JWK thumbprint doesn't match `expected_jkt`.
|
||||
JktMismatch,
|
||||
}
|
||||
|
||||
impl DpopVerifyError {
|
||||
/// Stable machine-readable reason string for audit lines.
|
||||
pub fn reason(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Malformed => "malformed_jws",
|
||||
Self::WrongTyp => "wrong_typ",
|
||||
Self::WrongAlg => "wrong_alg",
|
||||
Self::WrongJwk => "wrong_jwk",
|
||||
Self::SignatureInvalid => "signature_invalid",
|
||||
Self::WrongHtm => "wrong_htm",
|
||||
Self::WrongHtu => "wrong_htu",
|
||||
Self::IatMissing => "iat_missing",
|
||||
Self::IatOutOfWindow => "iat_out_of_window",
|
||||
Self::JtiMissing => "jti_missing",
|
||||
Self::JktMismatch => "jkt_mismatch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type DpopVerifyResult = Result<DpopVerified, DpopVerifyError>;
|
||||
|
||||
/// ±30s tolerance on the `iat` claim when NO nonce is present
|
||||
/// (bootstrap branch). Once Gate 5b lands, requests carrying a
|
||||
/// server-issued nonce bypass this check — nonce validity acts as
|
||||
/// the authoritative freshness signal.
|
||||
const IAT_BOOTSTRAP_TOLERANCE_SECS: i64 = 30;
|
||||
|
||||
/// Verify a DPoP proof against a request context. See file doc for
|
||||
/// scope: nonce/jti/replay checks are the caller's responsibility.
|
||||
pub fn verify(proof: &str, ctx: &DpopRequestContext<'_>) -> DpopVerifyResult {
|
||||
// ── 1. Split the compact JWS into three segments ─────────────
|
||||
let mut parts = proof.split('.');
|
||||
let (h_b64, p_b64, s_b64) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some(h), Some(p), Some(s), None) => (h, p, s),
|
||||
_ => return Err(DpopVerifyError::Malformed),
|
||||
};
|
||||
|
||||
let header_bytes = B64_URL_NO_PAD
|
||||
.decode(h_b64)
|
||||
.map_err(|_| DpopVerifyError::Malformed)?;
|
||||
let payload_bytes = B64_URL_NO_PAD
|
||||
.decode(p_b64)
|
||||
.map_err(|_| DpopVerifyError::Malformed)?;
|
||||
let signature = B64_URL_NO_PAD
|
||||
.decode(s_b64)
|
||||
.map_err(|_| DpopVerifyError::Malformed)?;
|
||||
|
||||
// ── 2. Parse header + validate typ/alg/jwk ────────────────────
|
||||
let header: serde_json::Value =
|
||||
serde_json::from_slice(&header_bytes).map_err(|_| DpopVerifyError::Malformed)?;
|
||||
|
||||
if header.get("typ").and_then(|v| v.as_str()) != Some("dpop+jwt") {
|
||||
return Err(DpopVerifyError::WrongTyp);
|
||||
}
|
||||
if header.get("alg").and_then(|v| v.as_str()) != Some("ES256") {
|
||||
return Err(DpopVerifyError::WrongAlg);
|
||||
}
|
||||
let jwk = header.get("jwk").ok_or(DpopVerifyError::WrongJwk)?;
|
||||
if jwk.get("kty").and_then(|v| v.as_str()) != Some("EC") {
|
||||
return Err(DpopVerifyError::WrongJwk);
|
||||
}
|
||||
if jwk.get("crv").and_then(|v| v.as_str()) != Some("P-256") {
|
||||
return Err(DpopVerifyError::WrongJwk);
|
||||
}
|
||||
let x_b64 = jwk
|
||||
.get("x")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(DpopVerifyError::WrongJwk)?;
|
||||
let y_b64 = jwk
|
||||
.get("y")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(DpopVerifyError::WrongJwk)?;
|
||||
|
||||
// ── 3. Verify the ECDSA signature ─────────────────────────────
|
||||
// JWS ES256 signature is raw R||S (64 bytes for P-256), NOT DER
|
||||
// — RFC 7515 A.3. `p256::ecdsa::Signature::from_slice` accepts
|
||||
// exactly that layout.
|
||||
let x_bytes = B64_URL_NO_PAD
|
||||
.decode(x_b64)
|
||||
.map_err(|_| DpopVerifyError::WrongJwk)?;
|
||||
let y_bytes = B64_URL_NO_PAD
|
||||
.decode(y_b64)
|
||||
.map_err(|_| DpopVerifyError::WrongJwk)?;
|
||||
if x_bytes.len() != 32 || y_bytes.len() != 32 {
|
||||
return Err(DpopVerifyError::WrongJwk);
|
||||
}
|
||||
// SEC1 uncompressed point: 0x04 || X || Y.
|
||||
let mut sec1 = Vec::with_capacity(65);
|
||||
sec1.push(0x04);
|
||||
sec1.extend_from_slice(&x_bytes);
|
||||
sec1.extend_from_slice(&y_bytes);
|
||||
|
||||
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
let vkey = VerifyingKey::from_sec1_bytes(&sec1).map_err(|_| DpopVerifyError::WrongJwk)?;
|
||||
let sig = Signature::from_slice(&signature).map_err(|_| DpopVerifyError::SignatureInvalid)?;
|
||||
|
||||
// Signing input is EXACT bytes: base64url(header) || '.' ||
|
||||
// base64url(payload). Preserve the caller's encoding — do NOT
|
||||
// re-encode, since serde_json re-serialisation may reorder
|
||||
// members and break signature.
|
||||
let signing_input = format!("{h_b64}.{p_b64}");
|
||||
vkey.verify(signing_input.as_bytes(), &sig)
|
||||
.map_err(|_| DpopVerifyError::SignatureInvalid)?;
|
||||
|
||||
// ── 4. Parse claims + validate htm/htu/iat/jti ────────────────
|
||||
let claims: serde_json::Value =
|
||||
serde_json::from_slice(&payload_bytes).map_err(|_| DpopVerifyError::Malformed)?;
|
||||
|
||||
let htm = claims.get("htm").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !htm.eq_ignore_ascii_case(ctx.htm) {
|
||||
return Err(DpopVerifyError::WrongHtm);
|
||||
}
|
||||
let htu = claims.get("htu").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if htu != ctx.htu {
|
||||
return Err(DpopVerifyError::WrongHtu);
|
||||
}
|
||||
let iat = claims
|
||||
.get("iat")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or(DpopVerifyError::IatMissing)?;
|
||||
let jti = claims
|
||||
.get("jti")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(DpopVerifyError::JtiMissing)?
|
||||
.to_string();
|
||||
let nonce = claims
|
||||
.get("nonce")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned);
|
||||
|
||||
// iat freshness check: authoritative only when NO nonce is
|
||||
// present (bootstrap branch). Gate 5b will bypass this when a
|
||||
// nonce is available — nonce validity is server-clock-based, so
|
||||
// it moots any client-clock skew.
|
||||
if nonce.is_none() && (iat - ctx.now_secs).abs() > IAT_BOOTSTRAP_TOLERANCE_SECS {
|
||||
return Err(DpopVerifyError::IatOutOfWindow);
|
||||
}
|
||||
|
||||
// ── 5. Compute JWK thumbprint (RFC 7638 §3.2 EC members) ──────
|
||||
let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}"#,);
|
||||
let jkt = B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
|
||||
|
||||
if let Some(expected) = ctx.expected_jkt
|
||||
&& expected != jkt
|
||||
{
|
||||
return Err(DpopVerifyError::JktMismatch);
|
||||
}
|
||||
|
||||
Ok(DpopVerified {
|
||||
jkt,
|
||||
nonce,
|
||||
jti,
|
||||
iat,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||
|
||||
/// Deterministic-ish signing key for tests — derives 32 bytes from
|
||||
/// a seed byte so each test can hold its own without pulling in
|
||||
/// `rand` as a dev-dep. Any value 1..=127 works (P-256 scalar
|
||||
/// must be non-zero and < curve order); we spread bytes over the
|
||||
/// buffer so keys with adjacent seeds don't share high bits.
|
||||
fn test_key(seed: u8) -> SigningKey {
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, b) in bytes.iter_mut().enumerate() {
|
||||
*b = seed.wrapping_add(i as u8).wrapping_add(1);
|
||||
}
|
||||
SigningKey::from_bytes(&bytes.into()).expect("valid P-256 scalar")
|
||||
}
|
||||
|
||||
/// Build a signed DPoP proof for testing — mirrors what
|
||||
/// `frontend/src/lib/auth/dpop-proof.ts` produces on the client.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_proof(
|
||||
signing_key: &SigningKey,
|
||||
htm: &str,
|
||||
htu: &str,
|
||||
iat: i64,
|
||||
jti: &str,
|
||||
nonce: Option<&str>,
|
||||
override_alg: Option<&str>,
|
||||
override_typ: Option<&str>,
|
||||
) -> (String, String) {
|
||||
let vkey = signing_key.verifying_key();
|
||||
let encoded = vkey.to_encoded_point(false); // uncompressed
|
||||
let x = encoded.x().unwrap();
|
||||
let y = encoded.y().unwrap();
|
||||
let x_b64 = B64_URL_NO_PAD.encode(x);
|
||||
let y_b64 = B64_URL_NO_PAD.encode(y);
|
||||
|
||||
let header = serde_json::json!({
|
||||
"typ": override_typ.unwrap_or("dpop+jwt"),
|
||||
"alg": override_alg.unwrap_or("ES256"),
|
||||
"jwk": { "crv": "P-256", "kty": "EC", "x": x_b64, "y": y_b64 },
|
||||
});
|
||||
let mut claims = serde_json::json!({
|
||||
"htm": htm,
|
||||
"htu": htu,
|
||||
"iat": iat,
|
||||
"jti": jti,
|
||||
});
|
||||
if let Some(n) = nonce {
|
||||
claims.as_object_mut().unwrap().insert(
|
||||
"nonce".to_string(),
|
||||
serde_json::Value::String(n.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
let h_b64 = B64_URL_NO_PAD.encode(header.to_string());
|
||||
let p_b64 = B64_URL_NO_PAD.encode(claims.to_string());
|
||||
let signing_input = format!("{h_b64}.{p_b64}");
|
||||
let sig: Signature = signing_key.sign(signing_input.as_bytes());
|
||||
let s_b64 = B64_URL_NO_PAD.encode(sig.to_bytes());
|
||||
let proof = format!("{h_b64}.{p_b64}.{s_b64}");
|
||||
|
||||
// Compute canonical thumbprint the same way verify() does
|
||||
let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}"#,);
|
||||
let jkt = B64_URL_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
|
||||
|
||||
(proof, jkt)
|
||||
}
|
||||
|
||||
fn ctx<'a>(
|
||||
htm: &'a str,
|
||||
htu: &'a str,
|
||||
now: i64,
|
||||
expected_jkt: Option<&'a str>,
|
||||
) -> DpopRequestContext<'a> {
|
||||
DpopRequestContext {
|
||||
htm,
|
||||
htu,
|
||||
now_secs: now,
|
||||
expected_jkt,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_happy_path() {
|
||||
let sk = test_key(1);
|
||||
let (proof, jkt) = make_proof(
|
||||
&sk,
|
||||
"GET",
|
||||
"https://oxi.example/api/me",
|
||||
1_000_000,
|
||||
"jti-1",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let out = verify(
|
||||
&proof,
|
||||
&ctx("GET", "https://oxi.example/api/me", 1_000_000, Some(&jkt)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out.jkt, jkt);
|
||||
assert_eq!(out.jti, "jti-1");
|
||||
assert_eq!(out.nonce, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_htm() {
|
||||
let sk = test_key(1);
|
||||
let (proof, jkt) = make_proof(&sk, "POST", "https://x/a", 1_000_000, "j", None, None, None);
|
||||
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::WrongHtm);
|
||||
assert_eq!(err.reason(), "wrong_htm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_htu() {
|
||||
let sk = test_key(1);
|
||||
let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None);
|
||||
let err = verify(&proof, &ctx("GET", "https://x/b", 1_000_000, Some(&jkt))).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::WrongHtu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_alg() {
|
||||
let sk = test_key(1);
|
||||
let (proof, _jkt) = make_proof(
|
||||
&sk,
|
||||
"GET",
|
||||
"https://x/a",
|
||||
1_000_000,
|
||||
"j",
|
||||
None,
|
||||
Some("RS256"),
|
||||
None,
|
||||
);
|
||||
// Wrong alg reject fires BEFORE signature verify (alg is a
|
||||
// header field we check first). No expected_jkt needed —
|
||||
// we don't get that far.
|
||||
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, None)).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::WrongAlg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_typ() {
|
||||
let sk = test_key(1);
|
||||
let (proof, _jkt) = make_proof(
|
||||
&sk,
|
||||
"GET",
|
||||
"https://x/a",
|
||||
1_000_000,
|
||||
"j",
|
||||
None,
|
||||
None,
|
||||
Some("jwt"),
|
||||
);
|
||||
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, None)).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::WrongTyp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_expired_iat_when_no_nonce() {
|
||||
let sk = test_key(1);
|
||||
let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None);
|
||||
// now = iat + 60 → outside ±30s tolerance
|
||||
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_060, Some(&jkt))).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::IatOutOfWindow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_stale_iat_when_nonce_present() {
|
||||
let sk = test_key(1);
|
||||
let (proof, jkt) = make_proof(
|
||||
&sk,
|
||||
"GET",
|
||||
"https://x/a",
|
||||
1_000_000,
|
||||
"j",
|
||||
Some("srv-nonce"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// now = iat + 10 minutes → would fail bootstrap check, but
|
||||
// nonce is present → freshness check delegated to nonce
|
||||
// validity (Gate 5b), so we accept here.
|
||||
let out = verify(&proof, &ctx("GET", "https://x/a", 1_000_600, Some(&jkt))).unwrap();
|
||||
assert_eq!(out.nonce.as_deref(), Some("srv-nonce"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_jkt_mismatch() {
|
||||
let sk = test_key(1);
|
||||
let (proof, _jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None);
|
||||
let err = verify(
|
||||
&proof,
|
||||
&ctx(
|
||||
"GET",
|
||||
"https://x/a",
|
||||
1_000_000,
|
||||
Some("some-other-thumbprint-value"),
|
||||
),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::JktMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_signature_when_payload_tampered() {
|
||||
let sk = test_key(1);
|
||||
let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "j", None, None, None);
|
||||
// Corrupt the middle segment (payload) — signature will no
|
||||
// longer verify against the tampered signing-input bytes.
|
||||
let mut parts: Vec<&str> = proof.split('.').collect();
|
||||
parts[1] = "bm90LWEtcmVhbC1wYXlsb2Fk"; // "not-a-real-payload"
|
||||
let tampered = parts.join(".");
|
||||
let err = verify(&tampered, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::SignatureInvalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_jws() {
|
||||
// Only 2 segments
|
||||
let err = verify("aa.bb", &ctx("GET", "https://x/a", 0, None)).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::Malformed);
|
||||
// 4 segments
|
||||
let err = verify("a.b.c.d", &ctx("GET", "https://x/a", 0, None)).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::Malformed);
|
||||
// Bad base64
|
||||
let err = verify("!!.??.@@", &ctx("GET", "https://x/a", 0, None)).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::Malformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_jti_is_rejected() {
|
||||
// Build a proof with an empty jti — verify() rejects because
|
||||
// it's essential for the replay cache to key on.
|
||||
let sk = test_key(1);
|
||||
let (proof, jkt) = make_proof(&sk, "GET", "https://x/a", 1_000_000, "", None, None, None);
|
||||
let err = verify(&proof, &ctx("GET", "https://x/a", 1_000_000, Some(&jkt))).unwrap_err();
|
||||
assert_eq!(err, DpopVerifyError::JtiMissing);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ pub mod compression_service;
|
||||
pub mod consistency_batch_service;
|
||||
pub mod db_pool_monitor;
|
||||
pub mod dedup_service;
|
||||
pub mod dpop_nonce_service;
|
||||
pub mod dpop_verifier;
|
||||
pub mod drives_consistency_service;
|
||||
pub mod encrypted_blob_backend;
|
||||
pub mod entry_backend;
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! DPoP proof enforcement middleware (RFC 9449).
|
||||
//!
|
||||
//! Runs AFTER the auth middleware — reads the `CurrentUser`
|
||||
//! extension to know the caller has an authenticated session, and
|
||||
//! validates the `DPoP` header against the session's stored JWK
|
||||
//! thumbprint (`session.dpop_jkt`).
|
||||
//!
|
||||
//! Mode dispatch (from `OXICLOUD_DPOP_MODE`):
|
||||
//! * **Off** — pass-through, no work. Safe default.
|
||||
//! * **Opportunistic** — verify when present, allow when absent.
|
||||
//! Rollout mode: catches client bugs before enforcement.
|
||||
//! * **Required** — bound sessions MUST present a valid proof.
|
||||
//! Unbound sessions (`dpop_jkt IS NULL`) remain exempt (app
|
||||
//! passwords, legacy).
|
||||
//!
|
||||
//! Failure response shape mirrors RFC 9449 §7.1:
|
||||
//! * generic bad proof → `401` + `WWW-Authenticate: DPoP
|
||||
//! error="invalid_dpop_proof"`
|
||||
//! * nonce missing / stale (Gate 5b) → `401` +
|
||||
//! `WWW-Authenticate: DPoP error="use_dpop_nonce"` +
|
||||
//! `DPoP-Nonce: <fresh>` — client retries once, transparently.
|
||||
//!
|
||||
//! Body is JSON `{"error_type": "DpopVerificationFailed"}` in both
|
||||
//! cases (anti-enumeration: same shape regardless of reason; the
|
||||
//! audit line carries the machine-readable reason).
|
||||
//!
|
||||
//! Every response — success OR failure — also gets a `DPoP-Nonce`
|
||||
//! header pointing at the currently-fresh server-issued nonce.
|
||||
//! Clients cache it; the next request presents it and skips the
|
||||
//! challenge round trip.
|
||||
//!
|
||||
//! Replay detection (jti-per-nonce) is Gate 6; not wired yet.
|
||||
|
||||
use axum::extract::{OriginalUri, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::config::DpopMode;
|
||||
use crate::common::di::AppState;
|
||||
use crate::infrastructure::services::dpop_verifier::{
|
||||
DpopRequestContext, DpopVerifyError, verify as verify_proof,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
|
||||
/// Resolve the request's external `(scheme, host)` — what the client
|
||||
/// sees the URL as, which is what its DPoP proof's `htu` was built
|
||||
/// from. Behind a reverse proxy, the internal request scheme +
|
||||
/// authority differ from the external ones; without normalising here
|
||||
/// the verifier fires `wrong_htu` on every request.
|
||||
///
|
||||
/// Priority chain (RFC 7239-adjacent — mirror what oxicloud audit
|
||||
/// spans use for `client_ip`):
|
||||
/// 1. `X-Forwarded-Proto` + `X-Forwarded-Host`
|
||||
/// 2. `Host` header with scheme inferred from `is_https` request
|
||||
/// 3. Fallback (`http` + `localhost`) — dev-only, unrepresentative
|
||||
///
|
||||
/// NB: no trust-boundary check here. If your deployment lets
|
||||
/// arbitrary clients set `X-Forwarded-*`, they can already forge
|
||||
/// audit-log IPs everywhere else — that's an operator responsibility
|
||||
/// solved by the trusted-proxy config, not this helper.
|
||||
fn external_scheme_host(headers: &HeaderMap) -> (String, String) {
|
||||
let scheme = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_owned())
|
||||
.unwrap_or_else(|| "http".to_owned());
|
||||
let host = headers
|
||||
.get("x-forwarded-host")
|
||||
.or_else(|| headers.get("host"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_owned())
|
||||
.unwrap_or_else(|| "localhost".to_owned());
|
||||
(scheme, host)
|
||||
}
|
||||
|
||||
/// Middleware entry point mounted on authenticated `/api/*` subtrees.
|
||||
pub async fn require_dpop_layer(
|
||||
State(state): State<Arc<AppState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let mode = state.core.config.auth.dpop_mode;
|
||||
if mode == DpopMode::Off {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let nonce_service = state.dpop_nonce_service.clone();
|
||||
|
||||
// No authenticated user → pass through (upstream auth layer
|
||||
// already handled or will handle the 401). We only concern
|
||||
// ourselves with proof-carrying requests on authenticated paths.
|
||||
let Some(_current_user) = request.extensions().get::<Arc<CurrentUser>>() else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
|
||||
let dpop_header = request
|
||||
.headers()
|
||||
.get("DPoP")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
// GATE 5 SCOPE: session-level `dpop_jkt` lookup is deferred (see
|
||||
// module docstring). For opportunistic mode:
|
||||
// - proof present → verify with expected_jkt=None (accept any
|
||||
// jkt shape; still catches malformed/bad-sig/wrong-htu bugs)
|
||||
// - proof absent → pass through, no warning yet
|
||||
// For required mode: same for now — Gate 9 flips to real
|
||||
// per-session enforcement once session context is wired.
|
||||
let Some(proof) = dpop_header else {
|
||||
let response = next.run(request).await;
|
||||
return stamp_current_nonce(response, &nonce_service);
|
||||
};
|
||||
|
||||
// Build canonical htu — external scheme + host (`X-Forwarded-*`
|
||||
// aware) + OriginalUri path (nest-strip-safe). Query stripped
|
||||
// per RFC 9449 §4.2.
|
||||
let (scheme, host) = external_scheme_host(request.headers());
|
||||
let path = request
|
||||
.extensions()
|
||||
.get::<OriginalUri>()
|
||||
.map(|u| u.0.path().to_owned())
|
||||
.unwrap_or_else(|| request.uri().path().to_owned());
|
||||
let htu = format!("{scheme}://{host}{path}");
|
||||
|
||||
let method = request.method().as_str().to_owned();
|
||||
let now_secs = chrono::Utc::now().timestamp();
|
||||
|
||||
let ctx = DpopRequestContext {
|
||||
htm: &method,
|
||||
htu: &htu,
|
||||
now_secs,
|
||||
expected_jkt: None, // Session-level pin comes in a later gate
|
||||
};
|
||||
match verify_proof(&proof, &ctx) {
|
||||
Ok(verified) => {
|
||||
// Nonce validation: the verifier extracted the claim; if
|
||||
// present, it MUST be in our live pool. Absent → OK on
|
||||
// the bootstrap request, but the challenge below MUST
|
||||
// still fire so the very next request carries a nonce.
|
||||
match verified.nonce.as_deref() {
|
||||
Some(n) if !nonce_service.is_valid(n) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "dpop.verify_failed",
|
||||
reason = "nonce_stale",
|
||||
method = %method,
|
||||
htu = %htu,
|
||||
"👮🏻♂️ DPoP nonce stale — issuing challenge",
|
||||
);
|
||||
return nonce_challenge_response(&nonce_service);
|
||||
}
|
||||
None => {
|
||||
// No nonce presented at all → challenge so the
|
||||
// NEXT request carries one. The ±30s bootstrap
|
||||
// window at the verifier means this one still
|
||||
// succeeded, but we still want the client onto
|
||||
// the nonce path immediately.
|
||||
return nonce_challenge_response(&nonce_service);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let response = next.run(request).await;
|
||||
stamp_current_nonce(response, &nonce_service)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "dpop.verify_failed",
|
||||
reason = err.reason(),
|
||||
method = %method,
|
||||
htu = %htu,
|
||||
"👮🏻♂️ DPoP proof rejected",
|
||||
);
|
||||
dpop_verification_failed_response(err, &nonce_service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp the currently-fresh nonce onto the outgoing response so
|
||||
/// the client sees it and caches it for its next request. Called
|
||||
/// on EVERY successful passthrough — the client's fetch interceptor
|
||||
/// keeps its cached nonce in sync automatically.
|
||||
fn stamp_current_nonce(
|
||||
mut response: Response,
|
||||
nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService,
|
||||
) -> Response {
|
||||
let fresh = nonce_service.current_or_rotate();
|
||||
if let Ok(hv) = HeaderValue::from_str(&fresh) {
|
||||
response.headers_mut().insert("DPoP-Nonce", hv);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Build a `use_dpop_nonce` challenge response — 401 +
|
||||
/// WWW-Authenticate + DPoP-Nonce carrying a fresh nonce. The SPA
|
||||
/// fetch interceptor (Gate 4) auto-retries once with the new nonce
|
||||
/// so users don't experience a visible failure.
|
||||
fn nonce_challenge_response(
|
||||
nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService,
|
||||
) -> Response {
|
||||
let mut resp = AppError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"DPoP nonce required",
|
||||
"DpopVerificationFailed",
|
||||
)
|
||||
.into_response();
|
||||
resp.headers_mut().insert(
|
||||
"WWW-Authenticate",
|
||||
HeaderValue::from_static(r#"DPoP error="use_dpop_nonce""#),
|
||||
);
|
||||
let fresh = nonce_service.current_or_rotate();
|
||||
if let Ok(hv) = HeaderValue::from_str(&fresh) {
|
||||
resp.headers_mut().insert("DPoP-Nonce", hv);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Build the standardised 401 response for a rejected DPoP proof.
|
||||
/// Response shape: RFC 9449 §7.1 `WWW-Authenticate: DPoP error="…"`
|
||||
/// plus OxiCloud's `error_type` JSON body so the SPA can key off it.
|
||||
/// Also carries a fresh `DPoP-Nonce` so a client whose failure was
|
||||
/// nonce-shaped (rare after this refactor, but future error paths
|
||||
/// might need it) can retry immediately.
|
||||
fn dpop_verification_failed_response(
|
||||
err: DpopVerifyError,
|
||||
nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService,
|
||||
) -> Response {
|
||||
let mut resp = AppError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"DPoP proof verification failed",
|
||||
"DpopVerificationFailed",
|
||||
)
|
||||
.into_response();
|
||||
// Static: `WWW-Authenticate` schemes stay stable across errors.
|
||||
// Only the audit `reason` field varies (already emitted).
|
||||
let www_auth = HeaderValue::from_static(r#"DPoP error="invalid_dpop_proof""#);
|
||||
resp.headers_mut().insert("WWW-Authenticate", www_auth);
|
||||
let fresh = nonce_service.current_or_rotate();
|
||||
if let Ok(hv) = HeaderValue::from_str(&fresh) {
|
||||
resp.headers_mut().insert("DPoP-Nonce", hv);
|
||||
}
|
||||
// Silence unused-parameter lint — err is captured in the audit
|
||||
// line at the callsite; this fn intentionally maps ALL failures
|
||||
// to the same client-facing shape (anti-enumeration).
|
||||
let _ = err;
|
||||
resp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
#[test]
|
||||
fn external_scheme_host_prefers_forwarded_headers() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("x-forwarded-proto", HeaderValue::from_static("https"));
|
||||
h.insert("x-forwarded-host", HeaderValue::from_static("oxi.example"));
|
||||
h.insert("host", HeaderValue::from_static("internal:8086"));
|
||||
assert_eq!(
|
||||
external_scheme_host(&h),
|
||||
("https".to_owned(), "oxi.example".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_scheme_host_falls_back_to_host_header() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("host", HeaderValue::from_static("localhost:5173"));
|
||||
assert_eq!(
|
||||
external_scheme_host(&h),
|
||||
("http".to_owned(), "localhost:5173".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_scheme_host_takes_leftmost_of_forwarded_chain() {
|
||||
// Multiple hops → `X-Forwarded-*` becomes a comma-separated
|
||||
// list. RFC 7239 says the leftmost is the original client.
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
|
||||
h.insert(
|
||||
"x-forwarded-host",
|
||||
HeaderValue::from_static("oxi.example, internal"),
|
||||
);
|
||||
assert_eq!(
|
||||
external_scheme_host(&h),
|
||||
("https".to_owned(), "oxi.example".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_scheme_host_defaults_when_empty() {
|
||||
let h = HeaderMap::new();
|
||||
assert_eq!(
|
||||
external_scheme_host(&h),
|
||||
("http".to_owned(), "localhost".to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod dpop;
|
||||
pub mod locale;
|
||||
pub mod rate_limit;
|
||||
pub mod server_status;
|
||||
|
||||
Reference in New Issue
Block a user