Skip to content

Commit a6f60bf

Browse files
fix(webauthn): validate rp.id as a registrable domain suffix (fix #187) (#190)
Closes #187. Stacked on #188. ## What Replaces the strict-equality check between the JSON request's `rp.id` and the origin's effective domain with the spec-correct "registrable domain suffix of or equal to" relation from HTML §6.5 (referenced by WebAuthn L3 §5.1.3 step 7 and §5.1.7 step 9). Net effect: `rp.id = "example.org"` now works against an origin of `https://login.example.org`, while `rp.id = "co.uk"` against `https://example.co.uk` is still correctly rejected because `co.uk` is a public suffix. ## PSL strategy Per the design discussion in #173 (libwebauthn should not bundle and manage its own PSL): - Adds a `PublicSuffixList` trait with sync `registrable_domain` / `public_suffix` methods. - Provides a `DatFilePublicSuffixList` impl that reads a Public Suffix List `.dat` file at construction time. `from_system_file()` reads the standard `/usr/share/publicsuffix/public_suffix_list.dat`, kept current by the system package manager. - No PSL data is bundled in the libwebauthn crate. - `publicsuffix = "1.5"`. Same crate as #173, just used offline. ## Plumbing into the parsing API `WebAuthnIDL::from_json` and `FromIdlModel::from_idl_model` now take an additional `psl: &dyn PublicSuffixList` parameter. This is the simplest shape we discussed - explicit, no hidden state on `RequestOrigin`. Yes, it's another breaking signature change on top of #188; the 0.4.0 release is already breaking, so we eat the cost once. Origin parsing (`Origin::from_str`, `RequestOrigin::try_from`) stays purely syntactic. PSL is only consulted at validation time. ## Commits 1. Add `PublicSuffixList` trait and `DatFilePublicSuffixList` impl. 2. Use `PublicSuffixList` for the registrable-suffix check in `from_idl_model`. ## Tests - Unit tests on the suffix-check helper using a small `MockPublicSuffixList` (recognises `com`, `co.uk`, `org`, `net`). - Unit tests on the mock itself. - 2 new positive integration tests on `from_json` for both `MakeCredentialRequest` and `GetAssertionRequest`: rp.id-as-parent-registrable-suffix accepted, rp.id-as-eTLD rejected. ## Test plan - [x] `cargo build --workspace --all-targets --all-features` - [x] `cargo fmt --all -- --check` - [x] `cargo clippy --workspace --all-targets --all-features -- -D warnings` - [x] `cargo test --workspace` (151 tests) - [x] `cargo publish --dry-run -p libwebauthn`
1 parent 17e91f6 commit a6f60bf

10 files changed

Lines changed: 565 additions & 36 deletions

File tree

Cargo.lock

Lines changed: 60 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ _Looking for the D-Bus API proposal?_ Check out [credentialsd][credentialsd].
3838
- 🟢 Hybrid transport (caBLE v2): QR-initiated transactions
3939
- 🟢 Hybrid transport (caBLE v2): State-assisted transactions (remember this phone)
4040

41+
## Runtime requirements
42+
43+
Validating the relying party ID against the calling origin requires the [Public Suffix List][psl]. The built-in loader reads it from the standard system path. The `publicsuffix` package on Debian/Ubuntu or `publicsuffix-list` on Fedora and Arch installs it there, but these are not always present on minimal installs. Install explicitly if needed. Callers wiring their own list don't need a system package.
44+
4145
## Transports
4246

4347
| | FIDO U2F | WebAuthn (FIDO2) |
@@ -79,3 +83,4 @@ If you don't know where to start, check out the _Issues_ tab.
7983
[#17]: https://github.com/linux-credentials/libwebauthn/issues/17
8084
[#18]: https://github.com/linux-credentials/libwebauthn/issues/18
8185
[#31]: https://github.com/linux-credentials/libwebauthn/issues/31
86+
[psl]: https://publicsuffix.org/

libwebauthn/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ base64-url = "3.0.0"
3232
dbus = "0.9.5"
3333
tracing = "0.1.29"
3434
idna = "1.0.3"
35+
publicsuffix = { version = "1.5", default-features = false }
3536
url = "2.5"
3637
maplit = "1.0.2"
3738
sha2 = "0.10.2"

libwebauthn/examples/webauthn_json_hid.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use tokio::sync::broadcast::Receiver;
88
use tracing_subscriber::{self, EnvFilter};
99

1010
use libwebauthn::ops::webauthn::{
11-
GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, WebAuthnIDL as _,
12-
WebAuthnIDLResponse as _,
11+
DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin,
12+
WebAuthnIDL as _, WebAuthnIDLResponse as _,
1313
};
1414
use libwebauthn::pin::PinRequestReason;
1515
use libwebauthn::transport::hid::list_devices;
@@ -81,6 +81,9 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
8181

8282
let request_origin: RequestOrigin =
8383
"https://example.org".try_into().expect("Invalid origin");
84+
let psl = DatFilePublicSuffixList::from_system_file().expect(
85+
"PSL not available; install the publicsuffix-list package or pass an explicit path",
86+
);
8487
let request_json = r#"
8588
{
8689
"rp": {
@@ -106,7 +109,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
106109
}
107110
"#;
108111
let make_credentials_request: MakeCredentialRequest =
109-
MakeCredentialRequest::from_json(&request_origin, request_json)
112+
MakeCredentialRequest::from_json(&request_origin, &psl, request_json)
110113
.expect("Failed to parse request JSON");
111114
println!(
112115
"WebAuthn MakeCredential request: {:?}",
@@ -158,7 +161,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
158161
}
159162
"#;
160163
let get_assertion: GetAssertionRequest =
161-
GetAssertionRequest::from_json(&request_origin, request_json)
164+
GetAssertionRequest::from_json(&request_origin, &psl, request_json)
162165
.expect("Failed to parse request JSON");
163166
println!("WebAuthn GetAssertion request: {:?}", get_assertion);
164167

libwebauthn/src/ops/webauthn/get_assertion.rs

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ use crate::{
1313
HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson,
1414
PublicKeyCredentialRequestOptionsJSON,
1515
},
16+
origin::is_registrable_domain_suffix_or_equal,
1617
response::{
1718
AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON,
1819
AuthenticatorAssertionResponseJSON, HMACGetSecretOutputJSON, LargeBlobOutputJSON,
1920
PRFOutputJSON, PRFValuesJSON, ResponseSerializationError, WebAuthnIDLResponse,
2021
},
2122
Base64UrlString, FromIdlModel, JsonError,
2223
},
24+
psl::PublicSuffixList,
2325
Operation, WebAuthnIDL,
2426
},
2527
pin::PinUvAuthProtocol,
@@ -115,21 +117,25 @@ impl FromIdlModel<PublicKeyCredentialRequestOptionsJSON, GetAssertionRequestPars
115117
{
116118
fn from_idl_model(
117119
request_origin: &RequestOrigin,
120+
psl: &dyn PublicSuffixList,
118121
inner: PublicKeyCredentialRequestOptionsJSON,
119122
) -> Result<Self, GetAssertionRequestParsingError> {
120123
let effective_rp_id = request_origin.origin.host.as_str();
121-
if let Some(relying_party_id) = inner.relying_party_id.as_deref() {
124+
let resolved_rp_id = if let Some(relying_party_id) = inner.relying_party_id.as_deref() {
122125
let parsed = RelyingPartyId::try_from(relying_party_id).map_err(|err| {
123126
GetAssertionRequestParsingError::InvalidRelyingPartyId(err.to_string())
124127
})?;
125-
// TODO(#160): Add support for related origin per WebAuthn Level 3.
126-
if parsed.0 != effective_rp_id {
128+
// TODO(#160): Add related-origins fallback per WebAuthn L3 §5.11.
129+
if !is_registrable_domain_suffix_or_equal(&parsed.0, effective_rp_id, psl) {
127130
return Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
128131
parsed.0,
129132
effective_rp_id.to_string(),
130133
));
131134
}
132-
}
135+
parsed.0
136+
} else {
137+
effective_rp_id.to_string()
138+
};
133139

134140
let prf = match inner.extensions.as_ref() {
135141
Some(ext) => match &ext.prf {
@@ -158,7 +164,7 @@ impl FromIdlModel<PublicKeyCredentialRequestOptionsJSON, GetAssertionRequestPars
158164
.unwrap_or(DEFAULT_TIMEOUT);
159165

160166
Ok(GetAssertionRequest {
161-
relying_party_id: effective_rp_id.to_string(),
167+
relying_party_id: resolved_rp_id,
162168
challenge: inner.challenge.to_vec(),
163169
origin: request_origin.origin.to_string(),
164170
top_origin: request_origin.top_origin.as_ref().map(|o| o.to_string()),
@@ -575,6 +581,7 @@ mod tests {
575581

576582
use serde_bytes::ByteBuf;
577583

584+
use crate::ops::webauthn::psl::MockPublicSuffixList;
578585
use crate::ops::webauthn::{GetAssertionRequest, RequestOrigin};
579586
use crate::proto::ctap2::Ctap2PublicKeyCredentialType;
580587

@@ -629,8 +636,12 @@ mod tests {
629636
#[test]
630637
fn test_request_from_json_base() {
631638
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
632-
let req: GetAssertionRequest =
633-
GetAssertionRequest::from_json(&request_origin, REQUEST_BASE_JSON).unwrap();
639+
let req: GetAssertionRequest = GetAssertionRequest::from_json(
640+
&request_origin,
641+
&MockPublicSuffixList,
642+
REQUEST_BASE_JSON,
643+
)
644+
.unwrap();
634645
assert_eq!(req, request_base());
635646
}
636647

@@ -640,7 +651,8 @@ mod tests {
640651
let req_json = json_field_rm(REQUEST_BASE_JSON, "rpId");
641652

642653
let req: GetAssertionRequest =
643-
GetAssertionRequest::from_json(&request_origin, &req_json).unwrap();
654+
GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
655+
.unwrap();
644656
assert_eq!(req, request_base());
645657
}
646658

@@ -649,7 +661,8 @@ mod tests {
649661
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
650662
let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.org.""#);
651663

652-
let result = GetAssertionRequest::from_json(&request_origin, &req_json);
664+
let result =
665+
GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
653666
assert!(matches!(
654667
result,
655668
Err(GetAssertionRequestParsingError::InvalidRelyingPartyId(_))
@@ -661,7 +674,37 @@ mod tests {
661674
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
662675
let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""other.example.org""#);
663676

664-
let result = GetAssertionRequest::from_json(&request_origin, &req_json);
677+
let result =
678+
GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
679+
assert!(matches!(
680+
result,
681+
Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
682+
_,
683+
_
684+
))
685+
));
686+
}
687+
688+
#[test]
689+
fn test_request_from_json_rp_id_is_parent_registrable_suffix() {
690+
// origin = login.example.org, rp.id = example.org -> accepted.
691+
let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap();
692+
let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.org""#);
693+
694+
let req = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
695+
.unwrap();
696+
assert_eq!(req.relying_party_id, "example.org");
697+
assert_eq!(req.origin, "https://login.example.org");
698+
}
699+
700+
#[test]
701+
fn test_request_from_json_rp_id_is_etld_rejected() {
702+
// origin = example.co.uk, rp.id = co.uk (a public suffix) -> rejected.
703+
let request_origin: RequestOrigin = "https://example.co.uk".parse().unwrap();
704+
let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""co.uk""#);
705+
706+
let result =
707+
GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
665708
assert!(matches!(
666709
result,
667710
Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
@@ -677,7 +720,8 @@ mod tests {
677720
let req_json = json_field_rm(REQUEST_BASE_JSON, "allowCredentials");
678721

679722
let req: GetAssertionRequest =
680-
GetAssertionRequest::from_json(&request_origin, &req_json).unwrap();
723+
GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
724+
.unwrap();
681725
assert_eq!(
682726
req,
683727
GetAssertionRequest {
@@ -693,7 +737,8 @@ mod tests {
693737
let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout");
694738

695739
let req: GetAssertionRequest =
696-
GetAssertionRequest::from_json(&request_origin, &req_json).unwrap();
740+
GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
741+
.unwrap();
697742
assert_eq!(req.timeout, DEFAULT_TIMEOUT);
698743
}
699744

@@ -706,7 +751,8 @@ mod tests {
706751
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{}"#);
707752

708753
let req: GetAssertionRequest =
709-
GetAssertionRequest::from_json(&request_origin, &req_json).unwrap();
754+
GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
755+
.unwrap();
710756
assert_eq!(
711757
req.extensions,
712758
Some(GetAssertionRequestExtensions::default())
@@ -724,7 +770,8 @@ mod tests {
724770
);
725771

726772
let req: GetAssertionRequest =
727-
GetAssertionRequest::from_json(&request_origin, &req_json).unwrap();
773+
GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
774+
.unwrap();
728775
if let Some(GetAssertionRequestExtensions {
729776
prf:
730777
Some(PrfInput {

libwebauthn/src/ops/webauthn/idl/mod.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ pub use response::{
1616

1717
use origin::RequestOrigin;
1818

19+
use super::psl::PublicSuffixList;
20+
1921
use serde::de::DeserializeOwned;
2022
use serde_json;
2123

@@ -33,9 +35,13 @@ where
3335
/// The JSON model that this IDL can deserialize from.
3436
type IdlModel: DeserializeOwned;
3537

36-
fn from_json(request_origin: &RequestOrigin, json: &str) -> Result<Self, Self::Error> {
38+
fn from_json(
39+
request_origin: &RequestOrigin,
40+
psl: &dyn PublicSuffixList,
41+
json: &str,
42+
) -> Result<Self, Self::Error> {
3743
let idl_model: Self::IdlModel = serde_json::from_str(json)?;
38-
Self::from_idl_model(request_origin, idl_model).map_err(From::from)
44+
Self::from_idl_model(request_origin, psl, idl_model).map_err(From::from)
3945
}
4046
}
4147

@@ -44,5 +50,9 @@ where
4450
T: DeserializeOwned,
4551
E: std::error::Error,
4652
{
47-
fn from_idl_model(request_origin: &RequestOrigin, model: T) -> Result<Self, E>;
53+
fn from_idl_model(
54+
request_origin: &RequestOrigin,
55+
psl: &dyn PublicSuffixList,
56+
model: T,
57+
) -> Result<Self, E>;
4858
}

0 commit comments

Comments
 (0)