use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use anyhow::{Result, bail};
use chrono::{Duration, Utc};
use jsonwebtoken::Algorithm::*;
use jsonwebtoken::jwk::AlgorithmParameters::*;
use jsonwebtoken::jwk::{Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse};
use jsonwebtoken::{DecodingKey, Validation};
use reqwest::{Client, Url};
use sha2::{Digest, Sha256};
use crate::dbs::capabilities::NetTarget;
use crate::err::Error;
use crate::kvs::Datastore;
use crate::kvs::cache::ds::{CachedJwks, DatastoreCache, Entry, Lookup};
#[cfg(test)]
static CACHE_EXPIRATION: LazyLock<chrono::Duration> = LazyLock::new(|| Duration::seconds(1));
#[cfg(not(test))]
static CACHE_EXPIRATION: LazyLock<chrono::Duration> =
LazyLock::new(|| match std::env::var("SURREAL_JWKS_CACHE_EXPIRATION_SECONDS") {
Ok(seconds_str) => {
let seconds = seconds_str.parse::<u64>().expect(
"Expected a valid number of seconds for SURREAL_JWKS_CACHE_EXPIRATION_SECONDS",
);
Duration::seconds(seconds as i64)
}
Err(_) => {
Duration::seconds(43200) // Set default cache expiration of 12 hours
}
});
#[cfg(test)]
static CACHE_COOLDOWN: LazyLock<chrono::Duration> = LazyLock::new(|| Duration::seconds(300));
#[cfg(not(test))]
static CACHE_COOLDOWN: LazyLock<chrono::Duration> =
LazyLock::new(|| match std::env::var("SURREAL_JWKS_CACHE_COOLDOWN_SECONDS") {
Ok(seconds_str) => {
let seconds = seconds_str.parse::<u64>().expect(
"Expected a valid number of seconds for SURREAL_JWKS_CACHE_COOLDOWN_SECONDS",
);
Duration::seconds(seconds as i64)
}
Err(_) => {
Duration::seconds(300) // Set default cache refresh cooldown of 5 minutes
}
});
#[cfg(not(target_family = "wasm"))]
static REMOTE_TIMEOUT: LazyLock<chrono::Duration> =
LazyLock::new(|| match std::env::var("SURREAL_JWKS_REMOTE_TIMEOUT_MILLISECONDS") {
Ok(milliseconds_str) => {
let milliseconds = milliseconds_str.parse::<u64>().expect(
"Expected a valid number of milliseconds for SURREAL_JWKS_REMOTE_TIMEOUT_MILLISECONDS",
);
Duration::milliseconds(milliseconds as i64)
}
Err(_) => {
Duration::milliseconds(1000) // Set default remote timeout to 1 second
}
});
// Generates a verification configuration from a JWKS object hosted in a remote
// location Performs local caching of all JWKS objects to prevent unnecessary
// network requests Implements checks to prevent denial of service and
// unauthorized network requests Validates the JWK objects found in the JWKS
// object according to RFC 7517 Source: https://datatracker.ietf.org/doc/html/rfc7517
pub(super) async fn config(
kvs: &Datastore,
kid: &str,
url: &str,
token_alg: jsonwebtoken::Algorithm,
) -> Result<(DecodingKey, Validation)> {
let cache = kvs.cache();
// Attempt to fetch relevant JWK object either from local cache or remote
// location
let jwk = match fetch_jwks_from_cache(cache.as_ref(), url) {
Some(cached) => {
trace!("Successfully fetched JWKS object from local cache");
// Check that the cached JWKS object has not expired yet
if Utc::now().signed_duration_since(cached.time) < *CACHE_EXPIRATION {
// Attempt to find JWK in JWKS object from local cache
match cached.jwks.find(kid) {
Some(jwk) => jwk.to_owned(),
_ => {
trace!(
"Could not find valid JWK object with key identifier '{kid}' in cached JWKS object"
);
// Check that the cached JWKS object has not been recently updated
if Utc::now().signed_duration_since(cached.time) < *CACHE_COOLDOWN {
debug!("Refused to refresh cache before cooldown period is over");
bail!(Error::InvalidAuth); // Return opaque error
}
find_jwk_from_url(kvs, url, kid).await?
}
}
} else {
trace!("Fetched JWKS object from local cache has expired");
find_jwk_from_url(kvs, url, kid).await?
}
}
None => {
trace!("Could not fetch JWKS object from local cache");
find_jwk_from_url(kvs, url, kid).await?
}
};
// Use algorithm provided, if specified
// This parameter is not required to be present, although is usually expected
// When missing, tokens must be validated using only the required key type
// parameter This is discouraged, as it requires relying on the algorithm
// specified in the token Source: https://datatracker.ietf.org/doc/html/rfc7517#section-4.4
let alg = match jwk.common.key_algorithm {
Some(alg) => match alg {
KeyAlgorithm::HS256 => HS256,
KeyAlgorithm::HS384 => HS384,
KeyAlgorithm::HS512 => HS512,
KeyAlgorithm::EdDSA => EdDSA,
KeyAlgorithm::ES256 => ES256,
KeyAlgorithm::ES384 => ES384,
KeyAlgorithm::PS256 => PS256,
KeyAlgorithm::PS384 => PS384,
KeyAlgorithm::PS512 => PS512,
KeyAlgorithm::RS256 => RS256,
KeyAlgorithm::RS384 => RS384,
KeyAlgorithm::RS512 => RS512,
_ => {
warn!("Unspported value for parameter 'alg' in JWK object: '{:?}'", alg);
bail!(Error::InvalidAuth); // Return opaque error
}
},
// If not specified, use the algorithm provided in the token header
// It is critical that the JWT library prevents the "none" algorithm from being used
// Reference: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/#Meet-the--None--Algorithm
// In the case of "jsonwebtoken", the "none" algorithm is not part of the enumeration
// Source: https://docs.rs/jsonwebtoken/latest/jsonwebtoken/enum.Algorithm.html
// Confirmation: https://github.com/Keats/jsonwebtoken/issues/381
_ => {
// Ensure that the algorithm specified in the token matches the key type defined
// in the JWK
match (&jwk.algorithm, token_alg) {
(RSA(_), RS256 | RS384 | RS512 | PS256 | PS384 | PS512) => token_alg,
(RSA(key), _) => {
warn!(
"Algorithm from token '{:?}' does not match JWK key type '{:?}'",
token_alg, key.key_type
);
bail!(Error::InvalidAuth); // Return opaque error
}
(EllipticCurve(_), ES256 | ES384) => token_alg,
(EllipticCurve(key), _) => {
warn!(
"Algorithm from token '{:?}' does not match JWK key type '{:?}'",
token_alg, key.key_type
);
bail!(Error::InvalidAuth); // Return opaque error
}
(OctetKey(_), HS256 | HS384 | HS512) => token_alg,
(OctetKey(key), _) => {
warn!(
"Algorithm from token '{:?}' does not match JWK key type '{:?}'",
token_alg, key.key_type
);
bail!(Error::InvalidAuth); // Return opaque error
}
(OctetKeyPair(_), EdDSA) => token_alg,
(OctetKeyPair(key), _) => {
warn!(
"Algorithm from token '{:?}' does not match JWK key type '{:?}'",
token_alg, key.key_type
);
bail!(Error::InvalidAuth); // Return opaque error
}
}
}
};
// Check if the key use (if specified) is intended to be used for signing
// Source: https://datatracker.ietf.org/doc/html/rfc7517#section-4.2
match &jwk.common.public_key_use {
Some(PublicKeyUse::Signature) => (),
Some(key_use) => {
warn!("Invalid value for parameter 'use' in JWK object: '{:?}'", key_use);
bail!(Error::InvalidAuth); // Return opaque error
}
None => (),
}
// Check if the key operations (if specified) include verification
// Source: https://datatracker.ietf.org/doc/html/rfc7517#section-4.3
if let Some(ops) = &jwk.common.key_operations
&& !ops.contains(&KeyOperations::Verify)
{
warn!(
"Invalid values for parameter 'key_ops' in JWK object: '{:?}'",
jwk.common.key_operations
);
bail!(Error::InvalidAuth); // Return opaque error
}
// Return verification configuration if a decoding key can be retrieved from the
// JWK object
match DecodingKey::from_jwk(&jwk) {
Ok(dec) => {
let mut val = Validation::new(alg);
// TODO(gguillemas): This keeps the existing behavior as of SurrealDB
// 2.0.0-alpha.9. Up to that point, a fork of the "jsonwebtoken" crate in
// version 8.3.0 was being used. Now that the audience claim is validated by
// default, we could allow users to leverage this. This will most likely
// involve defining an audience string via "DEFINE ACCESS ... TYPE JWT".
val.validate_aud = false;
Ok((dec, val))
}
Err(err) => {
warn!("Failed to retrieve decoding key from JWK object: '{}'", err);
Err(anyhow::Error::new(Error::InvalidAuth)) // Return opaque error
}
}
}
// Checks if network access to a remote location is allowed by the datastore
// capabilities Attempts to find a relevant JWK object inside a JWKS object
// fetched from the remote location
async fn find_jwk_from_url(kvs: &Datastore, url: &str, kid: &str) -> Result<Jwk> {
// Check that the datastore capabilities allow connections to the URL host
if let Err(err) = check_capabilities_url(kvs, url) {
warn!("Network access to JWKS location is not allowed: '{}'", err);
bail!(Error::InvalidAuth); // Return opaque error
}
// Attempt to fetch JWKS object from remote location
match fetch_jwks_from_url(kvs, url).await {
Ok(jwks) => {
trace!("Successfully fetched JWKS object from remote location");
// Attempt to find JWK in JWKS by the key identifier
match jwks.find(kid) {
Some(jwk) => Ok(jwk.to_owned()),
_ => {
debug!(
"Failed to find JWK object with key identifier '{kid}' in remote JWKS object"
);
Err(anyhow::Error::new(Error::InvalidAuth)) // Return opaque error
}
}
}
Err(err) => {
warn!("Failed to fetch JWKS object from remote location: '{}'", err);
Err(anyhow::Error::new(Error::InvalidAuth)) // Return opaque error
}
}
}
// Returns an error if network access to the address from a given URL string is
// not allowed
fn check_capabilities_url(kvs: &Datastore, url: &str) -> Result<()> {
let url_parsed = match Url::parse(url) {
Ok(url) => url,
Err(_) => {
bail!(Error::InvalidUrl(url.to_string()));
}
};
let addr = match url_parsed.host_str() {
Some(host) => {
if let Some(port) = url_parsed.port() {
format!("{host}:{port}")
} else {
host.to_string()
}
}
None => {
bail!(Error::InvalidUrl(url.to_string()));
}
};
let target = match NetTarget::from_str(&addr) {
Ok(host) => host,
Err(_) => {
bail!(Error::InvalidUrl(url.to_string()));
}
};
if !kvs.allows_network_target(&target) {
warn!("Capabilities denied outgoing network connection attempt, target: '{target}'");
bail!(Error::InvalidUrl(url.to_string()));
}
trace!("Capabilities allowed outgoing network connection, target: '{target}'");
Ok(())
}
// Builds the HTTP client used to fetch JWKS objects.
//
// The JWKS path does not depend on the `http` feature, so it cannot reuse the
// datastore's protected `HttpClient`. Two complementary defences against
// server-side request forgery (SSRF) are installed here, mirroring the
// protections applied to the general-purpose HTTP client:
//
// 1. A redirect policy re-validates every redirect target host against the datastore's network
// capabilities — the same allow/deny check applied to the original URL by
// `check_capabilities_url`. Without it, the default `reqwest` client follows up to 10 redirects
// to arbitrary hosts (e.g. cloud metadata endpoints) that were never authorised.
//
// 2. A capability-aware DNS resolver (`crate::net::FilteringResolver`) re-checks the IP addresses
// each hostname resolves to. The host-string check above only inspects the URL text, so an
// allow-listed hostname that resolves to a loopback, link-local, cloud-metadata or private
// address would otherwise be fetched even though a direct URL to that IP would be denied
// (GHSA-5x4x-2946-qr67 — a sibling of the redirect SSRF). The resolver blocks
// private/special-use IPs unless they are explicitly allowed, and applies to redirect targets
// as well as the original URL.
#[cfg(not(target_family = "wasm"))]
fn build_jwks_client(kvs: &Datastore) -> Result<Client> {
use reqwest::redirect::Policy;
use crate::net::{FilteringResolver, NetFilter};
// Snapshot the capabilities and redirect budget so the policy closure (which
// must be `'static + Send + Sync`) does not borrow the datastore.
let capabilities = Arc::new(kvs.get_capabilities().clone());
let max_redirects = kvs.config().max_http_redirects;
// Build the DNS-level filter from the same capability snapshot before the
// `capabilities` handle is moved into the redirect policy closure below.
let filter = Arc::new(NetFilter {
allow: capabilities.allow_net.clone(),
deny: capabilities.deny_net.clone(),
});
let policy = Policy::custom(move |attempt| {
if attempt.previous().len() >= max_redirects {
return attempt.stop();
}
// Extract owned values from the borrowed URL before moving `attempt`.
let url_str = attempt.url().to_string();
// Re-validate the redirect target host (and explicit port, if any) against
// the configured network capabilities, mirroring `check_capabilities_url`.
let addr = match (attempt.url().host_str(), attempt.url().port()) {
(Some(host), Some(port)) => Some(format!("{host}:{port}")),
(Some(host), None) => Some(host.to_string()),
(None, _) => None,
};
match addr.as_deref().map(NetTarget::from_str) {
Some(Ok(target)) if capabilities.allows_network_target(&target) => attempt.follow(),
_ => {
warn!(
"Capabilities denied JWKS redirect to disallowed network target: '{url_str}'"
);
attempt.error(Error::InvalidUrl(url_str))
}
}
});
Ok(Client::builder()
.redirect(policy)
.dns_resolver(FilteringResolver::from_net_filter(filter))
.build()?)
}
// Attempts to fetch a JWKS object from a remote location and stores it in the
// cache if successful
async fn fetch_jwks_from_url(kvs: &Datastore, url: &str) -> Result<JwkSet> {
let cache = kvs.cache();
#[cfg(not(target_family = "wasm"))]
let client = build_jwks_client(kvs)?;
#[cfg(target_family = "wasm")]
let client = Client::new();
let req = client.get(url);
// Add a User-Agent header so that WAF rules don't reject the request
#[cfg(not(target_family = "wasm"))]
let req = req.header(reqwest::header::USER_AGENT, &kvs.config().surrealdb_user_agent);
#[cfg(not(target_family = "wasm"))]
let res = req.timeout((*REMOTE_TIMEOUT).to_std().expect("valid duration")).send().await?;
#[cfg(target_family = "wasm")]
let res = req.send().await?;
if !res.status().is_success() {
warn!(
"Unsuccessful HTTP status code received when fetching JWKS object from remote location: '{:?}'",
res.status()
);
bail!(Error::InvalidAuth); // Return opaque error
}
let jwks = res.bytes().await?;
match serde_json::from_slice::<JwkSet>(&jwks) {
Ok(jwks) => {
// If successful, cache the JWKS object by its URL
store_jwks_in_cache(cache.as_ref(), jwks.clone(), url);
Ok(jwks)
}
Err(err) => {
warn!("Failed to parse malformed JWKS object: '{}'", err);
Err(anyhow::Error::new(Error::InvalidAuth)) // Return opaque error
}
}
}
// Attempts to fetch a JWKS object from the local cache
fn fetch_jwks_from_cache(cache: &DatastoreCache, url: &str) -> Option<Arc<CachedJwks>> {
let path = cache_key_from_url(url);
let entry = cache.get(&Lookup::Jwk(path.as_str()))?;
entry.try_into_jwk().ok()
}
// Attempts to store a JWKS object in the local cache
fn store_jwks_in_cache(cache: &DatastoreCache, jwks: JwkSet, url: &str) {
let path = cache_key_from_url(url);
let entry = Entry::Jwk(Arc::new(CachedJwks {
jwks,
time: Utc::now(),
}));
cache.insert(Lookup::Jwk(path.as_str()), entry);
trace!("Stored JWKS object in local cache");
}
// Generates a unique cache key for a given URL string
fn cache_key_from_url(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url);
let result = hasher.finalize();
format!("{:x}", result)
}
#[cfg(test)]
mod tests {
use rand::distr::{Alphanumeric, SampleString};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::dbs::capabilities::{Capabilities, NetTarget, Targets};
// Use unique path to prevent accidental cache reuse
fn random_path() -> String {
Alphanumeric.sample_string(&mut rand::rng(), 8)
}
static DEFAULT_JWKS: LazyLock<JwkSet> = LazyLock::new(|| {
JwkSet{
keys: vec![Jwk{
common: jsonwebtoken::jwk::CommonParameters {
public_key_use: Some(jsonwebtoken::jwk::PublicKeyUse::Signature),
key_operations: None,
key_algorithm: Some(KeyAlgorithm::RS256),
key_id: Some("test_1".to_string()),
x509_url: None,
x509_chain: Some(vec![
"MIIDBTCCAe2gAwIBAgIJdeyDfUXHLwX+MA0GCSqGSIb3DQEBCwUAMCAxHjAcBgNVBAMTFWdlcmF1c2VyLmV1LmF1dGgwLmNvbTAeFw0yMzEwMzExNzI5MDBaFw0zNzA3MDkxNzI5MDBaMCAxHjAcBgNVBAMTFWdlcmF1c2VyLmV1LmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANp7Er60Z7sOjiyQqpbZIkQBldO/t+YrHT8Mk661kNz8MRGXPpQ26rkO2fRZMeWPpXVcwW0xxLG5oQ2Iwbm1JUuHYbLb6WA/d/et0sOkOoEI6MP0+MVqrGnro+D6XGoz4yP8m2w8C2u2yFxAc+wAt1AIMWNJIYhEX6tqrliGnitDCye2wXKchhe4WctUlHoUNfO/sgazPQ7ItqisUF/fNSRbHLRJyS2mm76FlDELDLnEyVwUaeV/2xie9F44AfOQzVk1asO18BH3v6YjOQ3L41XEfOm2DMPkLOOmtyM7yA7OeF/fvn6zN+SByza6cFW37IOKoJsmvxkzxeDUlWm9MWkCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUOeOmT9I3/MJ/zI/lS74gPQmAQfEwDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQBDue8iM90XJcLORvr6e+h15f5tlvVjZ/cAzv09487QSHJtUd6qwTlunEABS5818TgMEMFDRafQ7CDX3KaAAXFpo2bnyWY9c+Ozp0PWtp8ChunOs94ayaG+viO0AiTrIY28cc26ehNBZ/4gC4/1k0IlXEk8rd1e03hQuhNiy7SQaxS2f1xkJfR4vCeF8HTN5omjKvIMcWRqkkwFZm4gdgkMfi2lNsV8V6+HXyTc3XUIdcwOUcC+Ms/m+vKxnyxw0UGh0RliB1qBc0ADg83hOsXEqZjneHh1ZhqqVF4IkKSJTfK5ofcc14GqvpLjjTR3s2eX6zxdujzwf4gnHdxjVvdJ".to_string(),
]),
x509_sha1_fingerprint: None,
x509_sha256_fingerprint: None,
},
algorithm: jsonwebtoken::jwk::AlgorithmParameters::RSA(
jsonwebtoken::jwk::RSAKeyParameters{
key_type: jsonwebtoken::jwk::RSAKeyType::RSA,
n: "2nsSvrRnuw6OLJCqltkiRAGV07-35isdPwyTrrWQ3PwxEZc-lDbquQ7Z9Fkx5Y-ldVzBbTHEsbmhDYjBubUlS4dhstvpYD93963Sw6Q6gQjow_T4xWqsaeuj4PpcajPjI_ybbDwLa7bIXEBz7AC3UAgxY0khiERfq2quWIaeK0MLJ7bBcpyGF7hZy1SUehQ187-yBrM9Dsi2qKxQX981JFsctEnJLaabvoWUMQsMucTJXBRp5X_bGJ70XjgB85DNWTVqw7XwEfe_piM5DcvjVcR86bYMw-Qs46a3IzvIDs54X9--frM35IHLNrpwVbfsg4qgmya_GTPF4NSVab0xaQ".to_string(),
e: "AQAB".to_string(),
}
),
},
Jwk{
common: jsonwebtoken::jwk::CommonParameters {
public_key_use: Some(jsonwebtoken::jwk::PublicKeyUse::Signature),
key_operations: None,
key_algorithm: Some(KeyAlgorithm::RS256),
key_id: Some("test_2".to_string()),
x509_url: None,
x509_chain: Some(vec![
"MIIDBTCCAe2gAwIBAgIJUzJ062XCgOVQMA0GCSqGSIb3DQEBCwUAMCAxHjAcBgNVBAMTFWdlcmF1c2VyLmV1LmF1dGgwLmNvbTAeFw0yMzEwMzExNzI5MDBaFw0zNzA3MDkxNzI5MDBaMCAxHjAcBgNVBAMTFWdlcmF1c2VyLmV1LmF1dGgwLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL7TgjrojPySPO5kPBOiAgiS0guSpYkfb0AtLNVyVeANe5vgjJoBQDe7vAGm68SHft841GQBPp5KWpxDTO+liECVAnbdR9YHwuZWOGuPRVVwNqtVmS8A75YG/mTWGV4tr2h+dLjjV3jvV0hvXRJwVFShlUS9+BqgevFBoF6zxi5AHIx/k1tCg1y2fhSlzYUHxEiFRgx0RhtJfizyv9QHoLSY3RFI4QOAkPtYwN5C1X69nEHPK0Q+W+POkeV7wuMQZWTRRT+xZuYn+JIYQCQviZ52FoJsrTzOEO5jlmrUa9PMEJpn0Aw68OdyLHjQPsip8B2JSegoVP1LTc0tDoqVGqUCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUYp67WM42b2pqF7ES0LsFvAI/Qy8wDgYDVR0PAQH/BAQDAgKEMA0GCSqGSIb3DQEBCwUAA4IBAQANOhmYz0jxNJG6pZ0klNtH00E6SoEsM/MNYH+atraTVZNeqPLAZH9514gMcqdu7+rBfQ/pRjpQG1YbkdZGQBaq5cZNlE6hNCT4BSgKddBYsN0WbfTGxstDVdoXLySgGCYpyKO6Hek4ULxwAf1LmMyOYpn4JrECy4mYShsCcfe504qfzUTd7pz1VaZ4minclOhz0dZbgYa+slUepe0C2+w+T3US138x0lPB9C266SLDakb6n/JTum+Czn2xlFBf4K4w6eWuSknvTlRrqTGE8RX3vzOiKTM3hpDdjU7Tu7eNsZpLkDR1e+w33m5NMi9iYgJcyTGsIeeHr0xjrRPD9Dwh".to_string(),
]),
x509_sha1_fingerprint: None,
x509_sha256_fingerprint: None,
},
algorithm: jsonwebtoken::jwk::AlgorithmParameters::RSA(
jsonwebtoken::jwk::RSAKeyParameters{
key_type: jsonwebtoken::jwk::RSAKeyType::RSA,
n: "vtOCOuiM_JI87mQ8E6ICCJLSC5KliR9vQC0s1XJV4A17m-CMmgFAN7u8AabrxId-3zjUZAE-nkpanENM76WIQJUCdt1H1gfC5lY4a49FVXA2q1WZLwDvlgb-ZNYZXi2vaH50uONXeO9XSG9dEnBUVKGVRL34GqB68UGgXrPGLkAcjH-TW0KDXLZ-FKXNhQfESIVGDHRGG0l-LPK_1AegtJjdEUjhA4CQ-1jA3kLVfr2cQc8rRD5b486R5XvC4xBlZNFFP7Fm5if4khhAJC-JnnYWgmytPM4Q7mOWatRr08wQmmfQDDrw53IseNA-yKnwHYlJ6ChU_UtNzS0OipUapQ".to_string(),
e: "AQAB".to_string(),
}
),
}
],
}
});
#[tokio::test]
async fn test_golden_path() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.and(header("user-agent", "SurrealDB"))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get first token configuration from remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the first time: {:?}", res.err());
// Drop server to force usage of the local cache
drop(mock_server);
// Get second token configuration from local cache
let res = config(
&ds,
"test_2",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the second time: {:?}", res.err());
}
#[tokio::test]
async fn test_capabilities_default() {
let ds = Datastore::new("memory").await.unwrap();
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(0)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from unallowed remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_err(), "Unexpected success validating token from unallowed remote location");
}
#[tokio::test]
async fn test_capabilities_specific_port() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1:443").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(0)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from unallowed remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_err(), "Unexpected success validating token from unallowed remote location");
}
#[tokio::test]
async fn test_cache_expiration() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(2)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the first time: {:?}", res.err());
// Wait for cache to expire
std::thread::sleep((*CACHE_EXPIRATION + Duration::seconds(1)).to_std().unwrap());
// Get same token configuration again after cache has expired
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the second time: {:?}", res.err());
// The server will panic if it does not receive exactly two expected
// requests
}
#[tokio::test]
async fn test_cache_cooldown() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(1)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Use token with invalid key identifier claim to force cache refresh
let res = config(
&ds,
"invalid",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_err(), "Unexpected success validating token with invalid key identifier");
// Use token with invalid key identifier claim to force cache refresh again
// before cooldown
let res = config(
&ds,
"invalid",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_err(), "Unexpected success validating token with invalid key identifier");
// The server will panic if it receives more than the single expected
// request
}
#[tokio::test]
async fn test_cache_expiration_remote_down() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.up_to_n_times(1) // Only respond the first time
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from remote location
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(res.is_ok(), "Failed to validate token the first time: {:?}", res.err());
// Wait for cache to expire
std::thread::sleep((*CACHE_EXPIRATION + Duration::seconds(1)).to_std().unwrap());
// Get same token configuration again after cache has expired
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token with an expired cache and remote down"
);
}
#[tokio::test]
async fn test_no_algorithm() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.key_algorithm = None;
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_ok(),
"Failed to validate token with key that does not specify algorithm: {:?}",
res.err()
);
}
#[tokio::test]
// An attacker can issue token indicating that it has been signed with an HMAC
// algorithm If the original issuer was trusted using RSA, this may allow the
// attacker to sign the token with the public key This test verifies that
// SurrealDB will not trust a token specifying an algorithm that does not match
// the key type Reference: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/#RSA-or-HMAC
async fn test_no_algorithm_invalid() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.key_algorithm = None;
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
// The token is signed using HMAC
jsonwebtoken::Algorithm::HS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token signed with algorithm that does not match the defined key type"
);
}
#[tokio::test]
async fn test_unsupported_algorithm() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.key_algorithm = Some(KeyAlgorithm::RSA_OAEP_256);
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token with key specifies an unsupported algorithm"
);
}
#[tokio::test]
async fn test_no_key_use() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.public_key_use = None;
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_ok(),
"Failed to validate token with key that does not specify use: {:?}",
res.err()
);
}
#[tokio::test]
async fn test_key_use_enc() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.public_key_use = Some(jsonwebtoken::jwk::PublicKeyUse::Encryption);
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token with key that only supports encryption"
);
}
#[tokio::test]
async fn test_key_ops_encrypt_only() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let mut jwks = DEFAULT_JWKS.clone();
jwks.keys[0].common.key_operations = Some(vec![jsonwebtoken::jwk::KeyOperations::Encrypt]);
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200).set_body_json(jwks);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token with key that only supports encryption"
);
}
#[tokio::test]
async fn test_remote_down() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(500);
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.mount(&mock_server)
.await;
let url = mock_server.uri();
// Get token configuration from remote location responding with Internal Server
// Error
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token configuration with unavailable remote location"
);
}
#[tokio::test]
#[cfg(not(target_family = "wasm"))]
async fn test_remote_timeout() {
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("127.0.0.1").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
let jwks = DEFAULT_JWKS.clone();
let jwks_path = format!("{}/jwks.json", random_path());
let mock_server = MockServer::start().await;
let response = ResponseTemplate::new(200)
.set_body_json(jwks)
.set_delay((*REMOTE_TIMEOUT + Duration::seconds(10)).to_std().unwrap());
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(response)
.expect(1)
.mount(&mock_server)
.await;
let url = mock_server.uri();
let start_time = Utc::now();
// Get token configuration from remote location responding very slowly
let res = config(
&ds,
"test_1",
&format!("{}/{}", &url, &jwks_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"Unexpected success validating token configuration with unavailable remote location"
);
assert!(
Utc::now() - start_time < *REMOTE_TIMEOUT + Duration::seconds(1),
"Remote request was not aborted immediately after timeout"
);
}
// Reproduction for GHSA-h5rg-8p7f-47g2: SSRF via JWKS URL redirect following.
//
// The original (allow-listed) JWKS host responds with a 302 redirect to a
// different host/port that is NOT allow-listed. Before the fix, the JWKS
// client used a bare `reqwest::Client` that follows redirects to arbitrary
// hosts, so the request to the internal target was issued (SSRF). After the
// fix, the redirect target is re-validated against network capabilities and
// the redirect is refused, so the internal server is never contacted.
#[tokio::test]
#[cfg(not(target_family = "wasm"))]
async fn test_redirect_to_unallowed_target_is_blocked() {
// The "internal" server an SSRF redirect would attempt to reach. It must
// never receive a request (`expect(0)` is verified on drop).
let internal_server = MockServer::start().await;
let internal_path = format!("{}/jwks.json", random_path());
Mock::given(method("GET"))
.and(path(&internal_path))
.respond_with(ResponseTemplate::new(200).set_body_json(DEFAULT_JWKS.clone()))
.expect(0)
.mount(&internal_server)
.await;
let redirect_location = format!("{}/{}", internal_server.uri(), internal_path);
// The allow-listed server the JWKS URL points at; it 302-redirects to the
// internal server, whose port is not part of the allow list.
let public_server = MockServer::start().await;
let public_path = format!("{}/jwks.json", random_path());
Mock::given(method("GET"))
.and(path(&public_path))
.respond_with(
ResponseTemplate::new(302).insert_header("Location", redirect_location.as_str()),
)
.mount(&public_server)
.await;
// Allow only the public server's exact host:port.
let public_port = public_server.address().port();
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some(
[NetTarget::from_str(&format!("127.0.0.1:{public_port}")).unwrap()].into(),
),
))
.build_with_path("memory")
.await
.unwrap();
let res = config(
&ds,
"test_1",
&format!("{}/{}", public_server.uri(), public_path),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"JWKS fetch following an SSRF redirect to an unallowed target must fail"
);
// `internal_server` mock is configured with `.expect(0)`: the assertion is
// enforced when the server is dropped at the end of the test.
}
// Reproduction for GHSA-5x4x-2946-qr67: SSRF via a JWKS URL whose hostname is
// allow-listed but resolves to a private/loopback address.
//
// `check_capabilities_url` validates only the URL *host string*. Before the
// fix, the JWKS client used the default `reqwest` resolver, so an allow-listed
// hostname that resolves to loopback was fetched even though a direct URL to
// the same loopback IP is rejected. After the fix, the client installs a
// capability-aware DNS resolver that re-checks the resolved IP and blocks the
// private address, so the internal server is never contacted.
#[tokio::test]
#[cfg(not(target_family = "wasm"))]
async fn test_allowed_hostname_resolving_to_loopback_is_blocked() {
// JWKS server on loopback. With the fix it must never be reached via the
// allow-listed hostname (`expect(0)` is verified on drop).
let mock_server = MockServer::start().await;
let jwks_path = format!("{}/jwks.json", random_path());
Mock::given(method("GET"))
.and(path(&jwks_path))
.respond_with(ResponseTemplate::new(200).set_body_json(DEFAULT_JWKS.clone()))
.expect(0)
.mount(&mock_server)
.await;
let port = mock_server.address().port();
// Allow only the *hostname* `localhost`, not the loopback IP it resolves to.
let ds = Datastore::builder()
.with_capabilities(Capabilities::default().with_network_targets(
Targets::<NetTarget>::Some([NetTarget::from_str("localhost").unwrap()].into()),
))
.build_with_path("memory")
.await
.unwrap();
// Negative control: a direct loopback URL is rejected by the host-string
// capability check before any connection is attempted.
let direct = config(
&ds,
"test_1",
&format!("http://127.0.0.1:{port}/{jwks_path}"),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(direct.is_err(), "direct loopback URL should be rejected by capabilities");
// The allow-listed hostname resolves to loopback; the capability-aware
// resolver must block the resolved private IP, so the fetch fails and the
// server is never contacted.
let rebinding = config(
&ds,
"test_1",
&format!("http://localhost:{port}/{jwks_path}"),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
rebinding.is_err(),
"JWKS fetch to an allow-listed hostname resolving to a private IP must be blocked"
);
// `mock_server` is configured with `.expect(0)`: enforced on drop.
}
// Reproduction for GHSA-5x4x-2946-qr67 on the redirect path: the
// capability-aware resolver must also guard redirect hops. A redirect to an
// allow-listed *hostname* that resolves to loopback passes the redirect
// host-string check, so only the resolver can block the resolved private IP.
#[tokio::test]
#[cfg(not(target_family = "wasm"))]
async fn test_redirect_to_allowed_hostname_resolving_to_loopback_is_blocked() {
// Internal server the redirect points at, via an allow-listed hostname
// that resolves to loopback. It must never receive a request.
let internal_server = MockServer::start().await;
let internal_path = format!("{}/jwks.json", random_path());
Mock::given(method("GET"))
.and(path(&internal_path))
.respond_with(ResponseTemplate::new(200).set_body_json(DEFAULT_JWKS.clone()))
.expect(0)
.mount(&internal_server)
.await;
let internal_port = internal_server.address().port();
// Point at the internal server via the `localhost` hostname (resolves to a
// loopback IP), not the literal IP.
let redirect_location = format!("http://localhost:{internal_port}/{internal_path}");
// Allow-listed public server that 302-redirects to the internal server.
let public_server = MockServer::start().await;
let public_path = format!("{}/jwks.json", random_path());
Mock::given(method("GET"))
.and(path(&public_path))
.respond_with(
ResponseTemplate::new(302).insert_header("Location", redirect_location.as_str()),
)
.mount(&public_server)
.await;
let public_port = public_server.address().port();
// Allow the public server by IP:port (first hop) and the internal host by
// name. The redirect host-string check passes for `localhost`, so the
// resolver is the only thing that can block the loopback IP it resolves to.
let ds = Datastore::builder()
.with_capabilities(
Capabilities::default().with_network_targets(Targets::<NetTarget>::Some(
[
NetTarget::from_str(&format!("127.0.0.1:{public_port}")).unwrap(),
NetTarget::from_str("localhost").unwrap(),
]
.into(),
)),
)
.build_with_path("memory")
.await
.unwrap();
let res = config(
&ds,
"test_1",
&format!("http://127.0.0.1:{public_port}/{public_path}"),
jsonwebtoken::Algorithm::RS256,
)
.await;
assert!(
res.is_err(),
"JWKS fetch following a redirect to an allow-listed hostname that resolves to loopback must be blocked"
);
// `internal_server` is configured with `.expect(0)`: enforced on drop.
}
}