Skip to content

Implement #160 Related Origins support (WebAuthn L3 § 5.11)#173

Open
HarveyOrourke15 wants to merge 5 commits intolinux-credentials:masterfrom
HarveyOrourke15:related-origins
Open

Implement #160 Related Origins support (WebAuthn L3 § 5.11)#173
HarveyOrourke15 wants to merge 5 commits intolinux-credentials:masterfrom
HarveyOrourke15:related-origins

Conversation

@HarveyOrourke15
Copy link

This pull request introduces a new implementation for validating related origins in accordance with the WebAuthn specification (§5.11.1).

  • Added a new related_origins module to libwebauthn.
  • Provided a public API (validate_related_origins) and a pluggable HTTP client trait (RelatedOriginsHttpClient) for extensible validation and document fetching.
  • ReqwestRelatedOriginsClient using the reqwest crate, enforces HTTPS, content type, content length, and streaming limits for secure fetching of well-known documents.
  • Added new dependencies: reqwest, bytes, url, and publicsuffix to Cargo.toml to support HTTP requests, streaming, URL parsing, and domain label extraction.

Testing and examples:

  • Added unit and property tests for the validator and domain label extraction, covering edge cases and policy limits.
  • Introduced a new example (debug_related_origins.rs) demonstrating environment-driven validation calls for debugging and manual testing.
  • Added an integration test for Amazon's related origins validation.

Harvey and others added 5 commits February 4, 2026 23:38
- Introduced `validate_related_origins` function to validate related origins according to WebAuthn §5.11.1.
- Implemented `RelatedOriginsHttpClient` trait for pluggable HTTP client functionality.
- Added `ReqwestRelatedOriginsClient` for fetching the well-known JSON document.
- Created `RelatedOriginsDocument` struct to deserialize the JSON response.
- Added error handling for various failure scenarios in related origins validation.
- Implemented unit tests for `validate_related_origins` with mock client.
- Updated `RelyingPartyId` struct to include validation against origins.
- Added dependencies for `reqwest`, `url`, `bytes`, and `publicsuffix` in `Cargo.toml`.
@AlfioEmanueleFresta
Copy link
Member

Thank you for your work on this @HarveyOrourke15! We'll try and review this as soon as possible.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds WebAuthn L3 §5.11 “Related Origins” validation to libwebauthn, including a default reqwest-backed fetcher, domain label extraction helper, and new tests/examples.

Changes:

  • Introduces ops::webauthn::related_origins module with a public validation API and pluggable HTTP client trait.
  • Implements ReqwestRelatedOriginsClient with HTTPS + content-type/length + streaming caps.
  • Adds new dependencies (reqwest, bytes, url, publicsuffix), plus a debug example and an integration test.

Reviewed changes

Copilot reviewed 6 out of 8 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
libwebauthn/src/ops/webauthn/related_origins/mod.rs Core validator + error types + registrable label helper + unit tests
libwebauthn/src/ops/webauthn/related_origins/client.rs Reqwest-based well-known document fetcher
libwebauthn/src/ops/webauthn/mod.rs Exposes related_origins module
libwebauthn/tests/related_origins.rs Adds an Amazon-related integration test
libwebauthn/examples/debug_related_origins.rs Adds a manual/debug executable for validation
libwebauthn/Cargo.toml Adds new dependencies for fetching/parsing
libwebauthn/Cargo.lock Locks transitive deps for the new crates
.gitignore Ignores VS Code launch config

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +195 to +199
if let Some(list_opt) = PS_LIST.get_or_init(|| publicsuffix::List::fetch().ok()) {
if let Ok(parsed) = list_opt.parse_domain(host) {
// If the parsed result provides the registrable root (eTLD+1),
// take the left-most label of that root as the registrable label.
if let Some(root) = parsed.root() {
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

publicsuffix::List::fetch() downloads the PSL over the network at runtime on first call. This can block the async runtime and introduces nondeterministic latency/failures (and may be disallowed in many deployments). Consider bundling a PSL snapshot with the crate (or using a non-network API from publicsuffix) so label extraction is offline and deterministic.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had this discussion before, with no clear solution. The list changes too often to rely only on compile time-snapshots. A hybrid approach would be thinkable, where we use both, try to fetch an updated version, and if that fails, fall back to the compile-time list.
But that would also increase the dependency tree and general bloaty-ness.
@AlfioEmanueleFresta , do you have a strong opinion on this either way?

Copy link
Member

@AlfioEmanueleFresta AlfioEmanueleFresta Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fetching a new list upon each request is expensive. A very outdated PSL is a security risk. I don't think we should try and manage a PSL ourselves if we can at all avoid it.

I think the ideal solution would be to use the system-managed PSL in /usr/share/publicsuffix. From a quick search, it seems to be widely available on most Linux distros. It's updated via package managers. We could fallback to downloading our own, but this should be cached for a reasonable time. I'm not sure what time time should be.

A compromise might be that of using a package like public-suffix, which ships with its own PSL. However, it doesn't seem to be updated very frequently (last update 10 months ago) and it also relies on the version of libwebauthn and publix-suffix in the chain of dependencies, which can get obsolete quickly.

WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the ideal solution would be to use the system-managed PSL in /usr/share/publicsuffix. From a quick search, it seems to be widely available on most Linux distros. It's updated via package managers. We could fallback to downloading our own, but this should be cached for a reasonable time. I'm not sure what time time should be.

I think using this package makes sense, but I think reading this file belongs in the credentialsd layer, not libwebauthn. For example, if someone wanted to build a browser, they probably already have a method to determine PSLs, so they would want to leverage their own infrastructure rather than requiring ours. In other words, I think libwebauthn could:

  • accept the public suffix list in a specific format, or
  • use a callback trait to allow the caller to verify a specific domain,

and leave it up to callers to provide the PSL verification. If necessary, we could provide a default PSL implementation under a non-default feature flag as a convenience.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @iinuwa:

  • The client should be able to override PSL behaviour.
  • I am also keen for libwebauthn to provide a safe, and implementation for the PSL.

I think a good compromise would be to:

  1. Define a PublicSuffixList trait, with a single async method to get the registrable domain as needed by the spec;
  2. Have libwebauthn include a simple implementation, based on publicsuffix reading a PSL from a static DAT file path.

E.g.:

#[async_trait]
pub trait PublicSuffixList: Send + Sync {
    /// Returns the registrable domain (eTLD+1) for the given host, if any.
    async fn registrable_domain(&self, host: &str) -> Option<String>;
}

And:

pub struct DatFilePublicSuffixList {
  fn new(path: &PathBuf) -> Option<Self> { ... }
  fn from_system_file() -> Option<Self> { 
    Self::new(PathBuf::from("/usr/share/publicsuffix/public_suffix_list.dat"))
  }
}

Clients like credentialsd can then decide whether to:

  • (a) Depend on the system package which provides the DAT file at a known location, using libwebauthn's verifier with the appropriate file path, or
  • (b) to provide theyr own implementation, which downloads & caches a PSL file

As for this specific PR, I think it would be fine just to implement and use the DatFilePublicSuffixList DAT-based PSL verifier component.

Building a PublicSuffixList trait, and allowing specifying/providing a PSL can be done as a follow up - by the same author or someone else.

WDYT?

Comment on lines +6 to +11
use std::time::Duration;
use std::sync::OnceLock;

use bytes::BytesMut;
use futures::TryStreamExt;

Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These imports are only used by the client submodule, not within this module, so they will trigger unused_imports warnings. Prefer moving the Duration/BytesMut/TryStreamExt imports into client.rs (and avoiding use super::* there) to keep module-level imports warning-free.

Suggested change
use std::time::Duration;
use std::sync::OnceLock;
use bytes::BytesMut;
use futures::TryStreamExt;
use std::sync::OnceLock;

Copilot uses AI. Check for mistakes.
Comment on lines +160 to +164
let client = match client::ReqwestRelatedOriginsClient::new() {
Ok(c) => c,
Err(e) => {
eprintln!("related_origins: failed to create HTTP client: {:?}", e);
return Err(e);
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Library code prints directly to stderr via eprintln!, which is inconsistent with the rest of the crate’s tracing-based logging and makes output harder to control for consumers. Consider using tracing::error! (or removing the logging and just returning the error).

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Please replace eprintln!() with tracing.

// Client implementation is kept in the `client` submodule.
pub mod client;

/// Implimentation of https://url.spec.whatwg.org/#host-public-suffix
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in doc comment: "Implimentation" should be "Implementation".

Suggested change
/// Implimentation of https://url.spec.whatwg.org/#host-public-suffix
/// Implementation of https://url.spec.whatwg.org/#host-public-suffix

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct.

Comment on lines +80 to +82
// Fetch the well-known URL for the RP ID
let response = client.fetch_well_known(rp_id_requested).await.map_err(|_| RelatedOriginsError::SecurityError)?;

Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch_well_known error is always mapped to SecurityError, discarding the underlying RelatedOriginsError and making HttpError effectively unreachable for request/response failures. If the spec requires returning SecurityError, consider at least logging the underlying error (via tracing) for diagnostics, or otherwise preserve it for callers of the lower-level API.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, tracing the error details would be good.

Comment on lines +38 to +42
async fn fetch_well_known(&self, rp_id: &str) -> Result<RelatedOriginsHttpResponse, RelatedOriginsError> {
let url = format!("https://{}/.well-known/webauthn", rp_id);

// When issuing the request, avoid cookies/auth and set no Referer:
let req = self.inner.get(&url).header(reqwest::header::REFERER, "");
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rp_id is interpolated directly into a URL string. Without validating/normalizing it, a caller could pass values containing characters like @, /, or : to alter the request target (SSRF / request smuggling style issues). Consider validating rp_id using the existing RelyingPartyId::try_from domain checks (and using the normalized ASCII value) before constructing the URL.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

additionally: Since this is something all clients would need to do, I wonder if we shouldn't just do this on the outside and pass the final well-known url in here.

serde_json = "1.0.141"
url = "2.4.0"
publicsuffix = "1.5"
reqwest = { version = "0.11", features = ["rustls-tls", "stream"] }
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reqwest is added with rustls-tls, but without default-features = false it will still pull in default TLS/native-tls and extra dependencies. If the intent is rustls-only (and to avoid OpenSSL/native TLS in builds), set default-features = false and enable only the needed features.

Suggested change
reqwest = { version = "0.11", features = ["rustls-tls", "stream"] }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "stream"] }

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that is true for newer reqwest-versions. Somebody would need to verify that.

Comment on lines +4 to +8
async fn amazon_validate_related_origins() -> Result<(), Box<dyn std::error::Error>> {
let caller_origin = "https://www.amazon.com";
let rp_id = "amazon.com";

let res = validate_related_origins(caller_origin, rp_id).await?;
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test performs a live network call to amazon.com, which will be flaky (DNS/timeout/rate limiting) and can fail in offline/sandboxed CI environments. Consider marking it #[ignore], gating behind an env/feature flag, or replacing it with a deterministic test using a mocked HTTP client / local test server.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, if we simply mark it #[ignore], it will just never get called/tested. I'm not sure how often it would actually fail in CI. @AlfioEmanueleFresta thoughts?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think our CI has network access other than crates.io (or, I hope it doesn't :)).

I would rather we build a test implementation of the HTTP client trait, and provide a mock list. This would allow tests to run automatically in CI, without an external dependency.

We should also avoid using real domain names in favour of reserved example domains.

Comment on lines +15 to +19
struct PolicyConfig {
pub max_body_bytes: usize,
pub max_origins: usize,
pub max_origin_len: usize,
pub max_labels: usize,
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PolicyConfig::max_body_bytes is defined, but the validator never enforces it (it relies on the reqwest client implementation). Since RelatedOriginsHttpClient is pluggable, consider enforcing response.body.len() <= max_body_bytes here (or removing the field if it’s intentionally client-only) so limits are consistent across implementations.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct.

Copy link
Collaborator

@msirringhaus msirringhaus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this! Looking good overall!
I left comments to some minor things

let resp = req.send().await.map_err(|e| RelatedOriginsError::HttpError(e.to_string()))?;

let status = resp.status();
if status != StatusCode::OK {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these checks happen outside in validate_related_origins_with_client() as well (status code, content type, content length, ..). Is there a reason to duplicate this here?

Comment on lines +15 to +19
struct PolicyConfig {
pub max_body_bytes: usize,
pub max_origins: usize,
pub max_origin_len: usize,
pub max_labels: usize,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct.

Comment on lines +38 to +42
async fn fetch_well_known(&self, rp_id: &str) -> Result<RelatedOriginsHttpResponse, RelatedOriginsError> {
let url = format!("https://{}/.well-known/webauthn", rp_id);

// When issuing the request, avoid cookies/auth and set no Referer:
let req = self.inner.get(&url).header(reqwest::header::REFERER, "");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

additionally: Since this is something all clients would need to do, I wonder if we shouldn't just do this on the outside and pass the final well-known url in here.

Comment on lines +80 to +82
// Fetch the well-known URL for the RP ID
let response = client.fetch_well_known(rp_id_requested).await.map_err(|_| RelatedOriginsError::SecurityError)?;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, tracing the error details would be good.

let response = client.fetch_well_known(rp_id_requested).await.map_err(|_| RelatedOriginsError::SecurityError)?;

if response.status != 200 {
return Err(RelatedOriginsError::SecurityError);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tracing-calls here and below, for each early return would be good, to pinpoint errors quickly.

let doc = r#"{"origins":["https://different.example.co.uk"]}"#;
let client = MockClient { body: doc.into() };
let res = validate_related_origins_with_client("https://caller.example.com", "example.com", &client).await.unwrap();
assert!(!res);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even more so here

let doc = r#"{"origins":[]}"#;
let client = MockClient { body: doc.into() };
let res = validate_related_origins_with_client("https://caller.example.com", "example.com", &client).await;
match res {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert_eq!(res, Err(RelatedOriginsError::SecurityError)) works here as well and is far shorter than the match. (One can also add additional output after, like assert_eq!(res, Err(..), "Needs to be SecurityError, because of..").)

let doc = r#"{"origins":["http://example.com"]}"#;
let client = MockClient { body: doc.into() };
let res = validate_related_origins_with_client("https://caller.example.com", "example.com", &client).await;
match res {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. assert_eq!(res, Err(RelatedOriginsError::SecurityError), "expected SecurityError for non-https origin, got {:?}", res) should do the trick

let doc = serde_json::json!({"origins": [origin]}).to_string();
let client = MockClient { body: doc };
let res = validate_related_origins_with_client("https://caller.example.com", "example.com", &client).await;
match res {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dito

Comment on lines +195 to +199
if let Some(list_opt) = PS_LIST.get_or_init(|| publicsuffix::List::fetch().ok()) {
if let Ok(parsed) = list_opt.parse_domain(host) {
// If the parsed result provides the registrable root (eTLD+1),
// take the left-most label of that root as the registrable label.
if let Some(root) = parsed.root() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had this discussion before, with no clear solution. The list changes too often to rely only on compile time-snapshots. A hybrid approach would be thinkable, where we use both, try to fetch an updated version, and if that fails, fall back to the compile-time list.
But that would also increase the dependency tree and general bloaty-ness.
@AlfioEmanueleFresta , do you have a strong opinion on this either way?

@iinuwa
Copy link
Member

iinuwa commented Feb 27, 2026

My apologies for not being able to look into this more right now, but I'd like to suggest that we implement some sort of caching policy for validating related origins.

One could imagine that if someone cancels a WebAuthn flow on a related origin request because they need to use a different authenticator, we don't want to make them fetch the related origins list again within seconds of each other.
I think the best way to do that is to make the validation of the related origin controllable by the caller so that it can save its own cache of related origins (e.g. based on the shortest of DNS TTL, HTTP cache expiry, and some local default policy (10 minutes?)). The calling application has this context of retries and timing between flows, but I think that would be harder to obtain in libwebauthn directly.

Copy link
Member

@AlfioEmanueleFresta AlfioEmanueleFresta left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for all your work so far on this @HarveyOrourke15!

Comment on lines +195 to +199
if let Some(list_opt) = PS_LIST.get_or_init(|| publicsuffix::List::fetch().ok()) {
if let Ok(parsed) = list_opt.parse_domain(host) {
// If the parsed result provides the registrable root (eTLD+1),
// take the left-most label of that root as the registrable label.
if let Some(root) = parsed.root() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @iinuwa:

  • The client should be able to override PSL behaviour.
  • I am also keen for libwebauthn to provide a safe, and implementation for the PSL.

I think a good compromise would be to:

  1. Define a PublicSuffixList trait, with a single async method to get the registrable domain as needed by the spec;
  2. Have libwebauthn include a simple implementation, based on publicsuffix reading a PSL from a static DAT file path.

E.g.:

#[async_trait]
pub trait PublicSuffixList: Send + Sync {
    /// Returns the registrable domain (eTLD+1) for the given host, if any.
    async fn registrable_domain(&self, host: &str) -> Option<String>;
}

And:

pub struct DatFilePublicSuffixList {
  fn new(path: &PathBuf) -> Option<Self> { ... }
  fn from_system_file() -> Option<Self> { 
    Self::new(PathBuf::from("/usr/share/publicsuffix/public_suffix_list.dat"))
  }
}

Clients like credentialsd can then decide whether to:

  • (a) Depend on the system package which provides the DAT file at a known location, using libwebauthn's verifier with the appropriate file path, or
  • (b) to provide theyr own implementation, which downloads & caches a PSL file

As for this specific PR, I think it would be fine just to implement and use the DatFilePublicSuffixList DAT-based PSL verifier component.

Building a PublicSuffixList trait, and allowing specifying/providing a PSL can be done as a follow up - by the same author or someone else.

WDYT?

@AlfioEmanueleFresta
Copy link
Member

AlfioEmanueleFresta commented Mar 1, 2026

@iinuwa:

I think the best way to do that is to make the validation of the related origin controllable by the caller so that it can save its own cache of related origins (e.g. based on the shortest of DNS TTL, HTTP cache expiry, and some local default policy (10 minutes?)). The calling application has this context of retries and timing between flows, but I think that would be harder to obtain in libwebauthn directly.

I agree with your concern re. the performance of repeat requests. However, as the spec for validating related origins is non-trivial, and is part of WebAuthn, IMO it belongs to libwebauthn.

As the most expensive part of the computation is retrieving the well-known list, I believe most of the performance concerns should be addressed by allowing the client to provide its own HTTP client/request logic - as intended by this PR and #160.

The caller would be free to implement caching policies as it best sees fit, based on HTTP response. Crucially, they would not need to re-implement related origin validation. WDYT?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants