Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 89 additions & 19 deletions libwebauthn/src/ops/webauthn/get_assertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
ops::webauthn::{
client_data::ClientData,
idl::{
appid_authorised,
get::{
HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson,
PublicKeyCredentialRequestOptionsJSON,
Expand Down Expand Up @@ -125,27 +126,22 @@ pub enum GetAssertionPrepareError {
#[error("Mismatching relying party ID: {0} != {1}")]
MismatchingRelyingPartyId(String, String),

/// The client must throw a "SecurityError" DOMException.
#[error("Invalid AppID: {0}")]
InvalidAppId(String),
}

/// Basic sanity check for FIDO AppID strings (WebAuthn L3 §10.1.1).
///
/// Per spec the AppID should be a same-site URL (typically `https://<rpid>/...`).
/// Full same-site validation against the rpId is not yet implemented; for now
/// we require non-empty input, an absolute URL form, and the `https` scheme.
fn validate_appid(appid: &str) -> Result<String, GetAssertionPrepareError> {
if appid.is_empty() {
return Err(GetAssertionPrepareError::InvalidAppId(
"appid must not be empty".to_string(),
));
}
// Sanity check: must be an https URL.
if !appid.starts_with("https://") {
return Err(GetAssertionPrepareError::InvalidAppId(format!(
"appid must be an https URL, got: {appid}"
)));
}
/// Validates a FIDO AppID string (WebAuthn L3 §10.1.1): a non-empty https URL
/// whose host is authorised for the caller origin (same-site check). Returns
/// the AppID unchanged on success.
async fn validate_appid(
request_origin: &RequestOrigin,
settings: &RequestSettings<'_>,
appid: &str,
) -> Result<String, GetAssertionPrepareError> {
appid_authorised(request_origin, settings, appid)
.await
.map_err(|err| GetAssertionPrepareError::InvalidAppId(err.to_string()))?;
Ok(appid.to_string())
}

Expand Down Expand Up @@ -204,7 +200,7 @@ impl FromIdlModel<PublicKeyCredentialRequestOptionsJSON> for GetAssertionRequest
};

let appid = match inner.extensions.as_ref().and_then(|e| e.appid.as_ref()) {
Some(s) => Some(validate_appid(s)?),
Some(s) => Some(validate_appid(request_origin, settings, s).await?),
None => None,
};

Expand Down Expand Up @@ -1094,7 +1090,8 @@ mod tests {

#[tokio::test]
async fn test_request_from_json_appid_extension() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
// Same-site AppID (equal host) is authorised for the caller.
let request_origin: RequestOrigin = "https://www.example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
"extensions",
Expand Down Expand Up @@ -1138,6 +1135,79 @@ mod tests {
));
}

#[tokio::test]
async fn test_request_from_json_appid_extension_rejects_cross_site() {
// WebAuthn L3 §10.1.1: a cross-site AppID is not authorised.
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
"extensions",
r#"{"appid":"https://example.net/u2f/origins.json"}"#,
);

let res = from_json(
&request_origin,
&MockPublicSuffixList,
RelatedOrigins::Disabled,
&req_json,
)
.await;
assert!(matches!(
res,
Err(GetAssertionPrepareError::InvalidAppId(_))
));
}

#[tokio::test]
async fn test_request_from_json_appid_extension_parent_domain() {
// Legacy U2F migration: a subdomain caller may use its registrable
// parent domain as the AppID (WebAuthn L3 §10.1.1).
let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
"extensions",
r#"{"appid":"https://example.org/u2f/origins.json"}"#,
);

let req: GetAssertionRequest = from_json(
&request_origin,
&MockPublicSuffixList,
RelatedOrigins::Disabled,
&req_json,
)
.await
.unwrap();
let ext = req.extensions.expect("extensions should be present");
assert_eq!(
ext.appid.as_deref(),
Some("https://example.org/u2f/origins.json")
);
}

#[tokio::test]
async fn test_request_from_json_appid_extension_rejects_subdomain() {
// The AppID host must be a suffix of the caller host, so a parent-domain
// caller cannot claim a more-specific subdomain AppID.
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
"extensions",
r#"{"appid":"https://sub.example.org/u2f/origins.json"}"#,
);

let res = from_json(
&request_origin,
&MockPublicSuffixList,
RelatedOrigins::Disabled,
&req_json,
)
.await;
assert!(matches!(
res,
Err(GetAssertionPrepareError::InvalidAppId(_))
));
}

#[test]
fn test_try_downgrade_with_appid_uses_appid_hash() {
use sha2::{Digest, Sha256};
Expand Down
47 changes: 47 additions & 0 deletions libwebauthn/src/ops/webauthn/idl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub use response::{
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use tracing::debug;
use url::Url;

use origin::{is_registrable_domain_suffix_or_equal, RequestOrigin};
use rpid::RelyingPartyId;
Expand Down Expand Up @@ -60,6 +61,52 @@ where
) -> Result<Self, Self::Error>;
}

/// Errors authorising a FIDO `appid` / `appidExclude` URL against the caller
/// origin (WebAuthn L3 §10.1.1 / §10.1.2).
#[derive(thiserror::Error, Debug)]
pub(crate) enum AppIdAuthorisationError {
#[error("appid must not be empty")]
Empty,
#[error("appid must be an https URL: {0}")]
NotHttps(String),
#[error("appid is not a valid URL: {0}")]
InvalidUrl(String),
#[error("appid has no host: {0}")]
NoHost(String),
#[error("appid host is not a valid domain: {0}")]
InvalidHost(String),
#[error("appid is not authorised for the caller origin")]
NotAuthorised,
}

/// Authorises a FIDO AppID URL for the caller, reusing the same-site rp.id
/// check: the AppID host must be a registrable-domain suffix of, or equal to,
/// the caller origin host (or pass related-origins). This is the web reduction
/// of the FIDO AppID and Facet "is a caller's FacetID authorized" algorithm.
pub(crate) async fn appid_authorised(
request_origin: &RequestOrigin,
settings: &RequestSettings<'_>,
appid: &str,
) -> Result<(), AppIdAuthorisationError> {
if appid.is_empty() {
return Err(AppIdAuthorisationError::Empty);
}
if !appid.starts_with("https://") {
return Err(AppIdAuthorisationError::NotHttps(appid.to_string()));
}
let url =
Url::parse(appid).map_err(|err| AppIdAuthorisationError::InvalidUrl(err.to_string()))?;
let host = url
.host_str()
.ok_or_else(|| AppIdAuthorisationError::NoHost(appid.to_string()))?;
let appid_rp = RelyingPartyId::try_from(host)
.map_err(|err| AppIdAuthorisationError::InvalidHost(err.to_string()))?;
if !rp_id_authorised(request_origin, &appid_rp, settings).await {
return Err(AppIdAuthorisationError::NotAuthorised);
}
Ok(())
}

/// Whether `request_origin` may act for `rp_id`. `Trust` accepts any rp.id;
/// `Validate` requires a registrable suffix of the caller's effective domain or
/// a matching related origin.
Expand Down
5 changes: 5 additions & 0 deletions libwebauthn/src/ops/webauthn/idl/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ pub struct AuthenticationExtensionsClientOutputsJSON {
#[serde(skip_serializing_if = "Option::is_none")]
pub appid: Option<bool>,

/// FIDO AppID Exclusion extension output (for registration, WebAuthn L3
/// §10.1.2): `Some(true)` when the appidExclude AppID was acted upon.
#[serde(skip_serializing_if = "Option::is_none")]
pub appid_exclude: Option<bool>,

/// The credential properties extension output (for registration).
#[serde(skip_serializing_if = "Option::is_none")]
pub cred_props: Option<CredentialPropertiesOutputJSON>,
Expand Down
Loading
Loading