use std::net::{IpAddr, Ipv4Addr};
use uuid::Uuid;
use crate::error::ApiError;
pub const VERIFICATION_TXT_PREFIX: &str = "_mockforge-verify";
const VERIFICATION_TXT_KEY: &str = "mockforge-verify";
pub fn normalize_domain(domain: &str) -> String {
domain.trim().trim_end_matches('.').to_ascii_lowercase()
}
pub fn verification_record_name(domain: &str) -> String {
format!("{VERIFICATION_TXT_PREFIX}.{}", normalize_domain(domain))
}
pub fn expected_txt_value(org_id: Uuid) -> String {
format!("{VERIFICATION_TXT_KEY}={org_id}")
}
pub fn domain_of_email(email: &str) -> Option<String> {
let (local, domain) = email.rsplit_once('@')?;
if local.is_empty() {
return None;
}
let domain = normalize_domain(domain);
if domain.is_empty() || domain.contains('@') || !domain.contains('.') {
return None;
}
Some(domain)
}
pub fn txt_records_authorize(records: &[String], org_id: Uuid) -> bool {
let expected = expected_txt_value(org_id);
records.iter().any(|r| r.trim() == expected)
}
#[async_trait::async_trait]
pub trait DomainOwnershipVerifier: Send + Sync {
async fn email_domain_verified(&self, org_id: Uuid, email: &str) -> bool;
}
pub struct DnsDomainVerifier;
#[async_trait::async_trait]
impl DomainOwnershipVerifier for DnsDomainVerifier {
async fn email_domain_verified(&self, org_id: Uuid, email: &str) -> bool {
let Some(domain) = domain_of_email(email) else {
tracing::warn!("SSO domain gate: assertion email has no usable domain; failing closed");
return false;
};
domain_ownership_verified(org_id, &domain).await
}
}
pub async fn domain_ownership_verified(org_id: Uuid, domain: &str) -> bool {
let domain = normalize_domain(domain);
let name = verification_record_name(&domain);
match lookup_txt_records(&name).await {
Ok(records) => {
let authorized = txt_records_authorize(&records, org_id);
if !authorized {
tracing::warn!(
org_id = %org_id,
domain = %domain,
"SSO domain check: no authorizing TXT record at {name}"
);
}
authorized
}
Err(e) => {
tracing::warn!(
org_id = %org_id,
domain = %domain,
error = %e,
"SSO domain check: TXT lookup failed; failing closed"
);
false
}
}
}
pub async fn assert_email_in_verified_domain(
verifier: &dyn DomainOwnershipVerifier,
org_id: Uuid,
email: &str,
) -> Result<(), ApiError> {
if verifier.email_domain_verified(org_id, email).await {
return Ok(());
}
let record_name = domain_of_email(email)
.map(|d| verification_record_name(&d))
.unwrap_or_else(|| format!("{VERIFICATION_TXT_PREFIX}.<your-domain>"));
Err(ApiError::InvalidRequest(format!(
"SSO provisioning blocked: this organization has not verified ownership of the \
email domain. Publish a DNS TXT record at \"{record_name}\" with value \
\"{}\" and try again.",
expected_txt_value(org_id),
)))
}
async fn lookup_txt_records(name: &str) -> Result<Vec<String>, String> {
use hickory_resolver::config::{ResolverConfig, CLOUDFLARE};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::proto::rr::{RData, RecordType};
use hickory_resolver::TokioResolver;
let builder = match TokioResolver::builder_tokio() {
Ok(b) => b,
Err(e) => {
tracing::debug!(error = %e, "system resolv.conf unreadable; falling back to Cloudflare");
TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
TokioRuntimeProvider::default(),
)
}
};
let resolver = builder.build().map_err(|e| format!("resolver build failed: {e}"))?;
let response = resolver.lookup(name, RecordType::TXT).await.map_err(|e| format!("{e}"))?;
let mut out = Vec::new();
for record in response.answers() {
let RData::TXT(ref txt) = record.data else {
continue;
};
let mut joined = String::new();
for chunk in txt.txt_data.iter() {
if let Ok(s) = std::str::from_utf8(chunk) {
joined.push_str(s);
}
}
if !joined.is_empty() {
out.push(joined);
}
}
Ok(out)
}
pub fn issuer_url_is_safe(raw: &str) -> bool {
let raw = raw.trim();
let Some(rest) = raw.strip_prefix("https://") else {
return false;
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
if authority.is_empty() || authority.contains('@') {
return false;
}
let host = if let Some(after_bracket) = authority.strip_prefix('[') {
match after_bracket.split(']').next() {
Some(h) if !h.is_empty() => h,
_ => return false,
}
} else {
authority.split(':').next().unwrap_or("")
};
if host.is_empty() {
return false;
}
!host_is_blocked(host)
}
pub fn allow_insecure_issuers() -> bool {
match std::env::var("MOCKFORGE_SSO_ALLOW_INSECURE_ISSUERS") {
Ok(v) if v == "1" || v.eq_ignore_ascii_case("true") => {
tracing::warn!(
"MOCKFORGE_SSO_ALLOW_INSECURE_ISSUERS is set: the OIDC issuer SSRF guard is \
RELAXED (localhost/http allowed). This must only be used in tests/dev."
);
true
}
_ => false,
}
}
pub fn fetch_url_is_safe(raw: &str) -> bool {
issuer_url_is_safe(raw) || allow_insecure_issuers()
}
pub fn validate_issuer_url(raw: &str) -> Result<(), ApiError> {
if fetch_url_is_safe(raw) {
Ok(())
} else {
Err(ApiError::InvalidRequest(
"OIDC issuer URL must be an https URL pointing at a public host (loopback, \
private, link-local, and metadata addresses are not allowed)."
.to_string(),
))
}
}
fn host_is_blocked(host: &str) -> bool {
let host = host.trim().to_ascii_lowercase();
if host == "localhost"
|| host == "ip6-localhost"
|| host == "metadata.google.internal"
|| host.ends_with(".localhost")
|| host.ends_with(".internal")
|| host.ends_with(".local")
{
return true;
}
if let Ok(ip) = host.parse::<IpAddr>() {
return match ip {
IpAddr::V4(v4) => ipv4_is_blocked(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return ipv4_is_blocked(v4);
}
let o = v6.octets();
let is_nat64 = o[..4] == [0x00, 0x64, 0xff, 0x9b] && o[4..12] == [0; 8];
if is_nat64 {
return ipv4_is_blocked(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
}
v6.is_loopback()
|| v6.is_unspecified()
|| (o[0] & 0xfe) == 0xfc
|| (o[0] == 0xfe && (o[1] & 0xc0) == 0x80)
}
};
}
false
}
fn ipv4_is_blocked(v4: Ipv4Addr) -> bool {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
|| (v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]))
}
#[cfg(test)]
mod tests {
use super::*;
fn org() -> Uuid {
Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
}
#[test]
fn normalizes_domains() {
assert_eq!(normalize_domain(" Example.COM. "), "example.com");
}
#[test]
fn verification_record_name_is_prefixed_and_normalized() {
assert_eq!(verification_record_name("Acme.Com"), "_mockforge-verify.acme.com");
}
#[test]
fn expected_txt_value_embeds_org_id() {
assert_eq!(
expected_txt_value(org()),
"mockforge-verify=11111111-1111-1111-1111-111111111111"
);
}
#[test]
fn domain_of_email_extracts_and_rejects_malformed() {
assert_eq!(domain_of_email("alice@Acme.com").as_deref(), Some("acme.com"));
assert_eq!(domain_of_email("alice@sub.acme.com").as_deref(), Some("sub.acme.com"));
assert_eq!(domain_of_email("alice@"), None);
assert_eq!(domain_of_email("alice"), None);
assert_eq!(domain_of_email("@acme.com"), None);
assert_eq!(domain_of_email("alice@localhost"), None);
}
#[test]
fn txt_records_authorize_requires_exact_org_match() {
let good = expected_txt_value(org());
assert!(txt_records_authorize(std::slice::from_ref(&good), org()));
assert!(txt_records_authorize(&[format!(" {good} ")], org()));
let other = Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap();
assert!(!txt_records_authorize(&[expected_txt_value(other)], org()));
assert!(!txt_records_authorize(&["v=spf1 -all".to_string()], org()));
assert!(!txt_records_authorize(&[], org()));
}
struct FakeVerifier {
verified: bool,
}
#[async_trait::async_trait]
impl DomainOwnershipVerifier for FakeVerifier {
async fn email_domain_verified(&self, _org_id: Uuid, _email: &str) -> bool {
self.verified
}
}
#[tokio::test]
async fn gate_allows_verified_domain() {
let v = FakeVerifier { verified: true };
assert!(assert_email_in_verified_domain(&v, org(), "alice@acme.com").await.is_ok());
}
#[tokio::test]
async fn gate_blocks_unverified_domain_with_instructions() {
let v = FakeVerifier { verified: false };
let err = assert_email_in_verified_domain(&v, org(), "attacker@bigcorp.com")
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("_mockforge-verify.bigcorp.com"), "got: {msg}");
assert!(msg.contains(&expected_txt_value(org())), "got: {msg}");
}
#[test]
fn ssrf_guard_allows_public_https_hosts() {
assert!(issuer_url_is_safe("https://login.okta.com/oauth2/default"));
assert!(issuer_url_is_safe("https://accounts.google.com"));
assert!(issuer_url_is_safe("https://idp.example.com:8443/realms/x"));
}
#[test]
fn ssrf_guard_blocks_dangerous_hosts() {
assert!(!issuer_url_is_safe("http://login.okta.com"));
assert!(!issuer_url_is_safe("https://localhost/x"));
assert!(!issuer_url_is_safe("https://127.0.0.1/x"));
assert!(!issuer_url_is_safe("https://[::1]/x"));
assert!(!issuer_url_is_safe("https://169.254.169.254/latest/meta-data"));
assert!(!issuer_url_is_safe("https://metadata.google.internal/x"));
assert!(!issuer_url_is_safe("https://[::ffff:169.254.169.254]/x"));
assert!(!issuer_url_is_safe("https://[::ffff:127.0.0.1]/x"));
assert!(!issuer_url_is_safe("https://[::ffff:10.0.0.1]/x"));
assert!(!issuer_url_is_safe("https://[64:ff9b::a9fe:a9fe]/x"));
assert!(!issuer_url_is_safe("https://10.0.0.5/x"));
assert!(!issuer_url_is_safe("https://192.168.1.1/x"));
assert!(!issuer_url_is_safe("https://172.16.4.4/x"));
assert!(!issuer_url_is_safe("https://user@evil.com/x"));
assert!(!issuer_url_is_safe("https:///x"));
}
}