From 15c89e7d0868bb23cc6ccf77dd59e2b416f432f0 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Fri, 23 Oct 2026 23:37:31 +0100 Subject: [PATCH 1/4] Add server-side Bearer challenges and credentials Adds a bearer module behind the server feature: a challenge builder for RFC 6750 section 3 (plus RFC 9728's resource_metadata), the three RFC 6750 error codes, and token parsing from Authorization: Bearer. --- CHANGELOG.md | 2 + README.md | 2 +- design/20261023-bearer.md | 42 ++++++++ src/bearer.rs | 12 +++ src/bearer/server.rs | 213 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 23 ++-- src/render.rs | 8 ++ src/table.rs | 10 ++ tests/bearer.rs | 147 ++++++++++++++++++++++++++ 9 files changed, 446 insertions(+), 13 deletions(-) create mode 100644 design/20261023-bearer.md create mode 100644 src/bearer.rs create mode 100644 src/bearer/server.rs create mode 100644 tests/bearer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9f160..ebd40f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ * `digest::Qop` now implements `PartialEq` and `Eq`. * `digest::QopSet` constructors: `From`, `BitOr`, `contains`, `is_empty`. +* `bearer::ChallengeBuilder`, `bearer::ErrorCode`, and `bearer::Credentials` + (server side). ### Changed diff --git a/README.md b/README.md index 403ad9c..6a260a3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Rust library for HTTP authentication. Parses challenge lists and responds to `Basic` and `Digest` challenges (client side), and issues challenges and -verifies `Basic` and `Digest` credentials (server side, `server` feature). +verifies `Basic`, `Digest`, and `Bearer` credentials (server side, `server` feature). HTTP authentication is described in the following documents and specifications: diff --git a/design/20261023-bearer.md b/design/20261023-bearer.md new file mode 100644 index 0000000..250df68 --- /dev/null +++ b/design/20261023-bearer.md @@ -0,0 +1,42 @@ +# Server-side `Bearer` challenges and credentials + +Date: 2026-10-23 + +# Problem statement + +A resource server accepting OAuth 2.0 access tokens sends `Bearer` challenges +([RFC 6750 section 3](https://datatracker.ietf.org/doc/html/rfc6750#section-3)), +optionally pointing to its metadata with `resource_metadata` +([RFC 9728 section 5.1](https://datatracker.ietf.org/doc/html/rfc9728#section-5.1)), +and reads the token from `Authorization: Bearer ` +([RFC 6750 section 2.1](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1)). + +Non-goals: token validation, metadata documents, grants, form-encoded or +query-string tokens. + +# Decision outcome + +Module `bearer`, behind `server`; no new dependencies. + +* `bearer::ChallengeBuilder::new()` with optional `realm`, `scope`, + `error`, `error_description`, `error_uri`, `resource_metadata`; `build()` + returns a `Challenge`, written in that order. +* `bearer::ErrorCode`: `InvalidRequest` (400), `InvalidToken` (401), + `InsufficientScope` (403), via `status()`. +* `bearer::Credentials::parse(source)` returns the `token68`, like + `basic::Credentials::parse`. + +## Validation rules + +| Rule | Error | +|---|---| +| `realm` quotable | `ConfigError` | +| `scope`: `scope-token`s (`%x21 / %x23-5B / %x5D-7E`) separated by single spaces | `ConfigError` | +| `error_description`, `error_uri`, `resource_metadata`: `%x20-21 / %x23-5B / %x5D-7E` | `ConfigError` | +| Credentials: body is a `token68` | `ServerError::Malformed` | + +# Testing + +* RFC 6750 section 3 and RFC 9728 section 5.1 examples. +* Every challenge parameter parsed back with `parse_challenges`. +* One negative test per validation rule. diff --git a/src/bearer.rs b/src/bearer.rs new file mode 100644 index 0000000..10b65dd --- /dev/null +++ b/src/bearer.rs @@ -0,0 +1,12 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `Bearer` authentication scheme, as in +//! [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). + +#[cfg(feature = "server")] +mod server; + +#[cfg(feature = "server")] +#[cfg_attr(docsrs, doc(cfg(feature = "server")))] +pub use self::server::{ChallengeBuilder, Credentials, ErrorCode}; diff --git a/src/bearer/server.rs b/src/bearer/server.rs new file mode 100644 index 0000000..1df2678 --- /dev/null +++ b/src/bearer/server.rs @@ -0,0 +1,213 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Server side of the `Bearer` scheme, as in +//! [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). + +use crate::render::HeaderWriter; +use crate::server::{check_realm, Challenge, ConfigError, CredentialsSource, ServerError}; +use crate::table::{char_classes, C_SCOPE_TOKEN}; +use crate::AuthBodyRef; + +/// An `error` value for a `Bearer` challenge, as in +/// [RFC 6750 section 3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ErrorCode { + /// The request is missing a required parameter or is otherwise malformed. + InvalidRequest, + /// The access token is expired, revoked, or otherwise invalid. + InvalidToken, + /// The token doesn't grant the requested scope. + InsufficientScope, +} + +impl ErrorCode { + /// The HTTP status code to respond with. + pub fn status(&self) -> u16 { + match self { + ErrorCode::InvalidRequest => 400, + ErrorCode::InvalidToken => 401, + ErrorCode::InsufficientScope => 403, + } + } + + /// The wire form of this error code. + pub fn as_str(&self) -> &'static str { + match self { + ErrorCode::InvalidRequest => "invalid_request", + ErrorCode::InvalidToken => "invalid_token", + ErrorCode::InsufficientScope => "insufficient_scope", + } + } +} + +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Builds a `Bearer` challenge. +/// +/// ```rust +/// use http_auth::bearer::{ChallengeBuilder, Credentials, ErrorCode}; +/// let challenge = ChallengeBuilder::new().realm("example").build()?; +/// assert_eq!(challenge.as_str(), r#"Bearer realm="example""#); +/// let creds = Credentials::parse("Bearer mF_9.B5f-4.1JqM").unwrap().unwrap(); +/// assert_eq!(creds.token(), "mF_9.B5f-4.1JqM"); +/// # Ok::<(), http_auth::ConfigError>(()) +/// ``` +#[derive(Clone, Debug)] +pub struct ChallengeBuilder { + realm: Option, + scope: Option, + error: Option, + error_description: Option, + error_uri: Option, + resource_metadata: Option, +} + +impl ChallengeBuilder { + /// Starts building a challenge with no parameters. + pub fn new() -> Self { + Self { + realm: None, + scope: None, + error: None, + error_description: None, + error_uri: None, + resource_metadata: None, + } + } + + /// Sets the `realm`. + pub fn realm(mut self, realm: impl Into) -> Self { + self.realm = Some(realm.into()); + self + } + + /// Sets the `scope`. + pub fn scope(mut self, scope: impl Into) -> Self { + self.scope = Some(scope.into()); + self + } + + /// Sets the `error`. + pub fn error(mut self, error: ErrorCode) -> Self { + self.error = Some(error); + self + } + + /// Sets the `error_description`. + pub fn error_description(mut self, error_description: impl Into) -> Self { + self.error_description = Some(error_description.into()); + self + } + + /// Sets the `error_uri`. + pub fn error_uri(mut self, error_uri: impl Into) -> Self { + self.error_uri = Some(error_uri.into()); + self + } + + /// Sets the `resource_metadata`. + pub fn resource_metadata(mut self, resource_metadata: impl Into) -> Self { + self.resource_metadata = Some(resource_metadata.into()); + self + } + + /// Returns the challenge, or [`ConfigError`] for an invalid value. + pub fn build(self) -> Result { + if let Some(realm) = &self.realm { + check_realm(realm)?; + } + if let Some(scope) = &self.scope { + if !valid_scope(scope) { + return Err(ConfigError("scope must be space-separated scope-tokens")); + } + } + let values = [ + self.error_description.as_deref(), + self.error_uri.as_deref(), + self.resource_metadata.as_deref(), + ]; + for value in values.iter().filter_map(|v| *v) { + if !crate::render::is_bearer_param(value) { + return Err(ConfigError("must be printable ASCII without \" or \\")); + } + } + + let mut w = HeaderWriter::new("Bearer"); + if let Some(realm) = &self.realm { + w.quoted("realm", realm); + } + if let Some(scope) = &self.scope { + w.quoted("scope", scope); + } + if let Some(error) = &self.error { + w.quoted("error", error.as_str()); + } + if let Some(error_description) = &self.error_description { + w.quoted("error_description", error_description); + } + if let Some(error_uri) = &self.error_uri { + w.quoted("error_uri", error_uri); + } + if let Some(resource_metadata) = &self.resource_metadata { + w.quoted("resource_metadata", resource_metadata); + } + Ok(Challenge(w.finish())) + } +} + +impl Default for ChallengeBuilder { + fn default() -> Self { + Self::new() + } +} + +fn valid_scope(s: &str) -> bool { + !s.is_empty() + && s.split(' ').all(|part| { + !part.is_empty() && part.bytes().all(|b| (char_classes(b) & C_SCOPE_TOKEN) != 0) + }) +} + +/// Credentials from a `Bearer` `Authorization` or `Proxy-Authorization` header. +#[derive(Clone)] +pub struct Credentials { + token: String, +} + +impl Credentials { + /// Parses a header value as in [RFC 6750 section + /// 2.1](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1). + /// + /// Returns `None` if `source` uses another scheme. See + /// [`CredentialsSource`] for accepted sources. + pub fn parse<'i, S: CredentialsSource<'i>>(source: S) -> Option> { + source.parse_scheme("Bearer", Self::from_body) + } + + fn from_body(body: &AuthBodyRef<'_>) -> Result { + let &AuthBodyRef::Token68(token) = body else { + return Err(ServerError::Malformed("expected token68")); + }; + Ok(Self { + token: token.to_owned(), + }) + } + + /// Returns the access token. + pub fn token(&self) -> &str { + &self.token + } +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("token", &"") + .finish() + } +} diff --git a/src/lib.rs b/src/lib.rs index 8a2dadb..ae74ac0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,10 @@ //! The 'Basic' HTTP Authentication Scheme //! * [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616): //! HTTP Digest Access Authentication +//! * [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750): +//! The OAuth 2.0 Authorization Framework: Bearer Token Usage +//! * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728): +//! OAuth 2.0 Protected Resource Metadata //! //! This framework is primarily used with HTTP, as suggested by the name. It is //! also used by some other protocols such as RTSP. @@ -89,26 +93,21 @@ pub mod basic; #[cfg_attr(docsrs, doc(cfg(feature = "digest-scheme")))] pub mod digest; +#[cfg(feature = "server")] +#[cfg_attr(docsrs, doc(cfg(feature = "server")))] +pub mod bearer; + mod table; -#[cfg(all( - feature = "server", - any(feature = "basic-scheme", feature = "digest-scheme") -))] +#[cfg(feature = "server")] mod server; -#[cfg(any( - feature = "digest-scheme", - all(feature = "server", feature = "basic-scheme") -))] +#[cfg(any(feature = "digest-scheme", feature = "server"))] mod render; pub use parser::{parse_credentials, ChallengeParser}; -#[cfg(all( - feature = "server", - any(feature = "basic-scheme", feature = "digest-scheme") -))] +#[cfg(feature = "server")] #[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub use server::{Challenge, ConfigError, CredentialsSource, ServerError}; diff --git a/src/render.rs b/src/render.rs index 74b237e..b3163ef 100644 --- a/src/render.rs +++ b/src/render.rs @@ -14,6 +14,14 @@ pub(crate) fn is_quotable(s: &str) -> bool { .all(|b| (char_classes(b) & (C_QDTEXT | C_ESCAPABLE)) != 0) } +/// Returns true if `s` consists only of bytes allowed in `Bearer` +/// `error_description`, `error_uri`, and `resource_metadata` values. +#[cfg(feature = "server")] +pub(crate) fn is_bearer_param(s: &str) -> bool { + s.bytes() + .all(|b| b == b' ' || (char_classes(b) & crate::table::C_SCOPE_TOKEN) != 0) +} + /// Writes a challenge or credentials header value. Callers validate values. pub(crate) struct HeaderWriter { out: String, diff --git a/src/table.rs b/src/table.rs index 9746127..0515b17 100644 --- a/src/table.rs +++ b/src/table.rs @@ -10,6 +10,7 @@ pub(crate) const C_QDTEXT: u8 = 2; pub(crate) const C_ESCAPABLE: u8 = 4; pub(crate) const C_OWS: u8 = 8; pub(crate) const C_ATTR: u8 = 16; +pub(crate) const C_SCOPE_TOKEN: u8 = 32; static TABLE: [u8; 128] = build_table(); @@ -40,6 +41,9 @@ const fn build_table() -> [u8; 128] { if is_attr(b) { classes |= C_ATTR; } + if is_scope_token(b) { + classes |= C_SCOPE_TOKEN; + } table[i] = classes; i += 1; } @@ -108,3 +112,9 @@ const fn is_attr(b: u8) -> bool { const fn is_ows(b: u8) -> bool { matches!(b, b' ' | b'\t') } + +/// Returns true if the byte is a `scope-token` character as in +/// [RFC 6750 section 1.1](https://datatracker.ietf.org/doc/html/rfc6750#section-1.1). +const fn is_scope_token(b: u8) -> bool { + matches!(b, 0x21 | 0x23..=0x5B | 0x5D..=0x7E) +} diff --git a/tests/bearer.rs b/tests/bearer.rs new file mode 100644 index 0000000..43a16f4 --- /dev/null +++ b/tests/bearer.rs @@ -0,0 +1,147 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `Bearer` challenges and credentials against RFC 6750 and RFC 9728. + +#![cfg(feature = "server")] + +use http_auth::bearer::{ChallengeBuilder, Credentials, ErrorCode}; + +fn params(header: &str) -> Vec<(String, String)> { + let c = http_auth::parse_challenges(header).unwrap(); + assert_eq!(c.len(), 1); + assert_eq!(c[0].scheme(), "Bearer"); + match c[0].body() { + http_auth::AuthBodyRef::Params(p) => p + .iter() + .map(|(k, v)| (k.to_string(), v.to_unescaped().into_owned())) + .collect(), + http_auth::AuthBodyRef::Token68(_) => panic!("token68 body"), + } +} + +#[test] +fn rfc6750_examples() { + let c = ChallengeBuilder::new().realm("example").build().unwrap(); + assert_eq!(c.as_str(), r#"Bearer realm="example""#); + let c = ChallengeBuilder::new() + .realm("example") + .error(ErrorCode::InvalidToken) + .error_description("The access token expired") + .build() + .unwrap(); + assert_eq!( + c.as_str(), + r#"Bearer realm="example", error="invalid_token", error_description="The access token expired""# + ); +} + +#[test] +fn rfc9728_example() { + let c = ChallengeBuilder::new() + .resource_metadata("https://resource.example.com/.well-known/oauth-protected-resource") + .build() + .unwrap(); + assert_eq!( + c.as_str(), + r#"Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource""# + ); +} + +#[test] +fn round_trip() { + let c = ChallengeBuilder::new() + .realm("zen \"ith\" \\") + .scope("live:read vod:read") + .error(ErrorCode::InsufficientScope) + .error_description("need vod:read") + .error_uri("https://example.com/e") + .resource_metadata("https://example.com/.well-known/oauth-protected-resource") + .build() + .unwrap(); + assert_eq!( + params(c.as_str()), + vec![ + ("realm".to_owned(), "zen \"ith\" \\".to_owned()), + ("scope".to_owned(), "live:read vod:read".to_owned()), + ("error".to_owned(), "insufficient_scope".to_owned()), + ("error_description".to_owned(), "need vod:read".to_owned()), + ("error_uri".to_owned(), "https://example.com/e".to_owned()), + ( + "resource_metadata".to_owned(), + "https://example.com/.well-known/oauth-protected-resource".to_owned() + ), + ] + ); + let c = ChallengeBuilder::new() + .error(ErrorCode::InvalidRequest) + .build() + .unwrap(); + assert_eq!( + params(c.as_str()), + vec![("error".to_owned(), "invalid_request".to_owned())] + ); + assert_eq!(ChallengeBuilder::new().build().unwrap().as_str(), "Bearer"); +} + +#[test] +fn rejects_invalid_values() { + assert!(ChallengeBuilder::new().realm("a\nb").build().is_err()); + assert!(ChallengeBuilder::new() + .error_description("say \"hi\"") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_description("back\\slash") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_description("tab\there") + .build() + .is_err()); + assert!(ChallengeBuilder::new().error_uri("a\"b").build().is_err()); + assert!(ChallengeBuilder::new().scope("a b").build().is_err()); + assert!(ChallengeBuilder::new().scope(" a").build().is_err()); + assert!(ChallengeBuilder::new().scope("a ").build().is_err()); + assert!(ChallengeBuilder::new().scope("").build().is_err()); + assert!(ChallengeBuilder::new().scope("a\"b").build().is_err()); + assert!(ChallengeBuilder::new().scope("caf\u{e9}").build().is_err()); + assert!(ChallengeBuilder::new() + .resource_metadata("x\"y") + .build() + .is_err()); + assert!(ChallengeBuilder::new().scope("a b").build().is_ok()); +} + +#[test] +fn error_status() { + assert_eq!(ErrorCode::InvalidRequest.status(), 400); + assert_eq!(ErrorCode::InvalidToken.status(), 401); + assert_eq!(ErrorCode::InsufficientScope.status(), 403); +} + +#[test] +fn credentials() { + let c = Credentials::parse("Bearer mF_9.B5f-4.1JqM") + .unwrap() + .unwrap(); + assert_eq!(c.token(), "mF_9.B5f-4.1JqM"); + let c = Credentials::parse("bearer abc+/=").unwrap().unwrap(); + assert_eq!(c.token(), "abc+/="); + let parsed = http_auth::parse_credentials("Bearer xyz").unwrap(); + assert_eq!(Credentials::parse(&parsed).unwrap().unwrap().token(), "xyz"); + + assert!(Credentials::parse("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==").is_none()); + assert!(matches!( + Credentials::parse("Bearer a=\"b\""), + Some(Err(http_auth::ServerError::Malformed(_))) + )); + assert!(matches!( + Credentials::parse("Bearer"), + Some(Err(http_auth::ServerError::Malformed(_))) + )); + assert!(matches!( + Credentials::parse("Bearer a b"), + Some(Err(http_auth::ServerError::Malformed(_))) + )); +} From 07912db71beb32b8d083fc8921f6b9f75016cb60 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Fri, 23 Oct 2026 23:46:04 +0100 Subject: [PATCH 2/4] Add Bearer client tests, RFC excerpts and full validation rules --- design/20261023-bearer-rfc.md | 365 ++++++++++++++++++++++++++++++++++ design/20261023-bearer.md | 71 ++++--- tests/bearer.rs | 185 ++++++++++++++++- 3 files changed, 592 insertions(+), 29 deletions(-) create mode 100644 design/20261023-bearer-rfc.md diff --git a/design/20261023-bearer-rfc.md b/design/20261023-bearer-rfc.md new file mode 100644 index 0000000..5f489f9 --- /dev/null +++ b/design/20261023-bearer-rfc.md @@ -0,0 +1,365 @@ +# RFC excerpts for `bearer` + +Verbatim from the RFCs the `bearer` module implements; page headers removed. +See [20261023-bearer.md](20261023-bearer.md). + +## RFC 6750 section 2.1: Authorization Request Header Field + +```text +2.1. Authorization Request Header Field + + When sending the access token in the "Authorization" request header + field defined by HTTP/1.1 [RFC2617], the client uses the "Bearer" + authentication scheme to transmit the access token. + + For example: + + GET /resource HTTP/1.1 + Host: server.example.com + Authorization: Bearer mF_9.B5f-4.1JqM + + The syntax of the "Authorization" header field for this scheme + follows the usage of the Basic scheme defined in Section 2 of + [RFC2617]. Note that, as with Basic, it does not conform to the + generic syntax defined in Section 1.2 of [RFC2617] but is compatible + with the general authentication framework being developed for + HTTP 1.1 [HTTP-AUTH], although it does not follow the preferred + practice outlined therein in order to reflect existing deployments. + The syntax for Bearer credentials is as follows: + + b64token = 1*( ALPHA / DIGIT / + "-" / "." / "_" / "~" / "+" / "/" ) *"=" + credentials = "Bearer" 1*SP b64token + + Clients SHOULD make authenticated requests with a bearer token using + the "Authorization" request header field with the "Bearer" HTTP + authorization scheme. Resource servers MUST support this method. +``` + +## RFC 6750 section 3: The WWW-Authenticate Response Header Field + +```text +3. The WWW-Authenticate Response Header Field + + If the protected resource request does not include authentication + credentials or does not contain an access token that enables access + to the protected resource, the resource server MUST include the HTTP + "WWW-Authenticate" response header field; it MAY include it in + response to other conditions as well. The "WWW-Authenticate" header + field uses the framework defined by HTTP/1.1 [RFC2617]. + + All challenges defined by this specification MUST use the auth-scheme + value "Bearer". This scheme MUST be followed by one or more + auth-param values. The auth-param attributes used or defined by this + specification are as follows. Other auth-param attributes MAY be + used as well. + + A "realm" attribute MAY be included to indicate the scope of + protection in the manner described in HTTP/1.1 [RFC2617]. The + "realm" attribute MUST NOT appear more than once. + + The "scope" attribute is defined in Section 3.3 of [RFC6749]. The + "scope" attribute is a space-delimited list of case-sensitive scope + values indicating the required scope of the access token for + accessing the requested resource. "scope" values are implementation + defined; there is no centralized registry for them; allowed values + are defined by the authorization server. The order of "scope" values + is not significant. In some cases, the "scope" value will be used + + when requesting a new access token with sufficient scope of access to + utilize the protected resource. Use of the "scope" attribute is + OPTIONAL. The "scope" attribute MUST NOT appear more than once. The + "scope" value is intended for programmatic use and is not meant to be + displayed to end-users. + + Two example scope values follow; these are taken from the OpenID + Connect [OpenID.Messages] and the Open Authentication Technology + Committee (OATC) Online Multimedia Authorization Protocol [OMAP] + OAuth 2.0 use cases, respectively: + + scope="openid profile email" + scope="urn:example:channel=HBO&urn:example:rating=G,PG-13" + + If the protected resource request included an access token and failed + authentication, the resource server SHOULD include the "error" + attribute to provide the client with the reason why the access + request was declined. The parameter value is described in + Section 3.1. In addition, the resource server MAY include the + "error_description" attribute to provide developers a human-readable + explanation that is not meant to be displayed to end-users. It also + MAY include the "error_uri" attribute with an absolute URI + identifying a human-readable web page explaining the error. The + "error", "error_description", and "error_uri" attributes MUST NOT + appear more than once. + + Values for the "scope" attribute (specified in Appendix A.4 of + [RFC6749]) MUST NOT include characters outside the set %x21 / %x23-5B + / %x5D-7E for representing scope values and %x20 for delimiters + between scope values. Values for the "error" and "error_description" + attributes (specified in Appendixes A.7 and A.8 of [RFC6749]) MUST + NOT include characters outside the set %x20-21 / %x23-5B / %x5D-7E. + Values for the "error_uri" attribute (specified in Appendix A.9 of + [RFC6749]) MUST conform to the URI-reference syntax and thus MUST NOT + include characters outside the set %x21 / %x23-5B / %x5D-7E. + + For example, in response to a protected resource request without + authentication: + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer realm="example" + + And in response to a protected resource request with an + authentication attempt using an expired access token: + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer realm="example", + error="invalid_token", + error_description="The access token expired" + +3.1. Error Codes + + When a request fails, the resource server responds using the + appropriate HTTP status code (typically, 400, 401, 403, or 405) and + includes one of the following error codes in the response: + + invalid_request + The request is missing a required parameter, includes an + unsupported parameter or parameter value, repeats the same + parameter, uses more than one method for including an access + token, or is otherwise malformed. The resource server SHOULD + respond with the HTTP 400 (Bad Request) status code. + + invalid_token + The access token provided is expired, revoked, malformed, or + invalid for other reasons. The resource SHOULD respond with + the HTTP 401 (Unauthorized) status code. The client MAY + request a new access token and retry the protected resource + request. + + insufficient_scope + The request requires higher privileges than provided by the + access token. The resource server SHOULD respond with the HTTP + 403 (Forbidden) status code and MAY include the "scope" + attribute with the scope necessary to access the protected + resource. + + If the request lacks any authentication information (e.g., the client + was unaware that authentication is necessary or attempted using an + unsupported authentication method), the resource server SHOULD NOT + include an error code or other error information. + + For example: + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer realm="example" +``` + +## RFC 6749 appendix A.4, A.7-A.9: `scope`, `error`, `error_description`, `error_uri` syntax + +```text +Appendix A. Augmented Backus-Naur Form (ABNF) Syntax + + This section provides Augmented Backus-Naur Form (ABNF) syntax + descriptions for the elements defined in this specification using the + notation of [RFC5234]. The ABNF below is defined in terms of Unicode + code points [W3C.REC-xml-20081126]; these characters are typically + encoded in UTF-8. Elements are presented in the order first defined. + + Some of the definitions that follow use the "URI-reference" + definition from [RFC3986]. + + Some of the definitions that follow use these common definitions: + + VSCHAR = %x20-7E + NQCHAR = %x21 / %x23-5B / %x5D-7E + NQSCHAR = %x20-21 / %x23-5B / %x5D-7E + UNICODECHARNOCRLF = %x09 /%x20-7E / %x80-D7FF / + %xE000-FFFD / %x10000-10FFFF + + (The UNICODECHARNOCRLF definition is based upon the Char definition + in Section 2.2 of [W3C.REC-xml-20081126], but omitting the Carriage + Return and Linefeed characters.) + +A.4. "scope" Syntax + + The "scope" element is defined in Section 3.3: + + scope = scope-token *( SP scope-token ) + scope-token = 1*NQCHAR + +A.7. "error" Syntax + + The "error" element is defined in Sections 4.1.2.1, 4.2.2.1, 5.2, + 7.2, and 8.5: + + error = 1*NQSCHAR + +A.8. "error_description" Syntax + + The "error_description" element is defined in Sections 4.1.2.1, + 4.2.2.1, 5.2, and 7.2: + + error-description = 1*NQSCHAR + +A.9. "error_uri" Syntax + + The "error_uri" element is defined in Sections 4.1.2.1, 4.2.2.1, 5.2, + and 7.2: + + error-uri = URI-reference +``` + +## RFC 9728 section 1.2: Terminology (resource identifier) + +```text +1.2. Terminology + + This specification uses the terms "access token", "authorization + code", "authorization server", "client", "client authentication", + "client identifier", "protected resource", and "resource server" + defined by OAuth 2.0 [RFC6749], and the terms "Claim Name" and "JSON + Web Token (JWT)" defined by "JSON Web Token (JWT)" [JWT]. + + This specification defines the following term: + + Resource Identifier: + The protected resource's resource identifier, which is a URL that + uses the https scheme and has no fragment component. As specified + in Section 2 of [RFC8707], it also SHOULD NOT include a query + component, but it is recognized that there are cases that make a + query component a useful and necessary part of a resource + identifier. Protected resource metadata is published at a .well- + known location [RFC8615] derived from this resource identifier, as + described in Section 3. +``` + +## RFC 9728 sections 5.1-5.2: WWW-Authenticate Response + +```text +5.1. WWW-Authenticate Response + + This specification introduces a new parameter in the WWW-Authenticate + HTTP response header field to indicate the protected resource + metadata URL: + + resource_metadata: + The URL of the protected resource metadata. + + The response below is an example of a WWW-Authenticate header that + includes the resource identifier. + + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer resource_metadata= + "https://resource.example.com/.well-known/oauth-protected-resource" + + The HTTP status code in the example response above is defined by + [RFC6750]. + + This parameter MAY also be used in WWW-Authenticate responses using + authorization schemes other than "Bearer" [RFC6750], such as the DPoP + scheme defined by [RFC9449]. + + The resource_metadata parameter MAY be combined with other parameters + defined in other extensions, such as the max_age parameter defined by + [RFC9470]. + +5.2. Changes to Resource Metadata + + At any point, for any reason determined by the resource server, the + protected resource MAY respond with a new WWW-Authenticate challenge + that includes a value for the protected resource metadata URL to + indicate that its metadata may have changed. If the client receives + such a WWW-Authenticate response, it SHOULD retrieve the updated + protected resource metadata and use the new metadata values obtained, + after validating them as described in Section 3.3. Among other + things, this enables a resource server to change which authorization + servers it uses without any other coordination with clients. +``` + +## RFC 3986 appendix A: Collected ABNF for URI + +```text +Appendix A. Collected ABNF for URI + + URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] + + hier-part = "//" authority path-abempty + / path-absolute + / path-rootless + / path-empty + + URI-reference = URI / relative-ref + + absolute-URI = scheme ":" hier-part [ "?" query ] + + relative-ref = relative-part [ "?" query ] [ "#" fragment ] + + relative-part = "//" authority path-abempty + / path-absolute + / path-noscheme + / path-empty + + scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + + authority = [ userinfo "@" ] host [ ":" port ] + userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) + host = IP-literal / IPv4address / reg-name + port = *DIGIT + + IP-literal = "[" ( IPv6address / IPvFuture ) "]" + + IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) + + IPv6address = 6( h16 ":" ) ls32 + / "::" 5( h16 ":" ) ls32 + / [ h16 ] "::" 4( h16 ":" ) ls32 + / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 + / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 + / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 + / [ *4( h16 ":" ) h16 ] "::" ls32 + / [ *5( h16 ":" ) h16 ] "::" h16 + / [ *6( h16 ":" ) h16 ] "::" + + h16 = 1*4HEXDIG + ls32 = ( h16 ":" h16 ) / IPv4address + IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet + + dec-octet = DIGIT ; 0-9 + / %x31-39 DIGIT ; 10-99 + / "1" 2DIGIT ; 100-199 + / "2" %x30-34 DIGIT ; 200-249 + / "25" %x30-35 ; 250-255 + + reg-name = *( unreserved / pct-encoded / sub-delims ) + + path = path-abempty ; begins with "/" or is empty + / path-absolute ; begins with "/" but not "//" + / path-noscheme ; begins with a non-colon segment + / path-rootless ; begins with a segment + / path-empty ; zero characters + + path-abempty = *( "/" segment ) + path-absolute = "/" [ segment-nz *( "/" segment ) ] + path-noscheme = segment-nz-nc *( "/" segment ) + path-rootless = segment-nz *( "/" segment ) + path-empty = 0 + + segment = *pchar + segment-nz = 1*pchar + segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" ) + ; non-zero-length segment without any colon ":" + + pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + + query = *( pchar / "/" / "?" ) + + fragment = *( pchar / "/" / "?" ) + + pct-encoded = "%" HEXDIG HEXDIG + + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + reserved = gen-delims / sub-delims + gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" + sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + / "*" / "+" / "," / ";" / "=" +``` diff --git a/design/20261023-bearer.md b/design/20261023-bearer.md index 250df68..5084381 100644 --- a/design/20261023-bearer.md +++ b/design/20261023-bearer.md @@ -1,42 +1,67 @@ -# Server-side `Bearer` challenges and credentials +# `Bearer` challenges and credentials Date: 2026-10-23 # Problem statement -A resource server accepting OAuth 2.0 access tokens sends `Bearer` challenges +OAuth 2.0 resource servers send `Bearer` challenges ([RFC 6750 section 3](https://datatracker.ietf.org/doc/html/rfc6750#section-3)), -optionally pointing to its metadata with `resource_metadata` +optionally with `resource_metadata` ([RFC 9728 section 5.1](https://datatracker.ietf.org/doc/html/rfc9728#section-5.1)), -and reads the token from `Authorization: Bearer ` +and read the token from `Authorization: Bearer ` ([RFC 6750 section 2.1](https://datatracker.ietf.org/doc/html/rfc6750#section-2.1)). +Clients read those challenges and send the token. -Non-goals: token validation, metadata documents, grants, form-encoded or -query-string tokens. +The RFC text is in [20261023-bearer-rfc.md](20261023-bearer-rfc.md). + +Non-goals: the optional form-body and query methods (RFC 6750 sections 2.2, +2.3); token validation; fetching or validating metadata (RFC 9728 section +3.3); transport security (RFC 6750 section 5). # Decision outcome -Module `bearer`, behind `server`; no new dependencies. +Module `bearer`. The client half has no feature gate and no dependencies; the +server half is behind `server`. + +* `bearer::Client`, from a `Bearer` `ChallengeRef`: `realm()`, `scope()`, + `error()`, `error_description()`, `error_uri()`, `resource_metadata()`, + and `respond(token)`. +* `bearer::encode_credentials(token)`, like `basic::encode_credentials`. +* `bearer::ChallengeBuilder`, `bearer::ErrorCode` (`status()` 400 / 401 / + 403) and `bearer::Credentials::parse`, behind `server`. -* `bearer::ChallengeBuilder::new()` with optional `realm`, `scope`, - `error`, `error_description`, `error_uri`, `resource_metadata`; `build()` - returns a `Challenge`, written in that order. -* `bearer::ErrorCode`: `InvalidRequest` (400), `InvalidToken` (401), - `InsufficientScope` (403), via `status()`. -* `bearer::Credentials::parse(source)` returns the `token68`, like - `basic::Credentials::parse`. +The client and server check challenge values with the same functions, so a +challenge the builder writes is one the client accepts, and the reverse. ## Validation rules -| Rule | Error | -|---|---| -| `realm` quotable | `ConfigError` | -| `scope`: `scope-token`s (`%x21 / %x23-5B / %x5D-7E`) separated by single spaces | `ConfigError` | -| `error_description`, `error_uri`, `resource_metadata`: `%x20-21 / %x23-5B / %x5D-7E` | `ConfigError` | -| Credentials: body is a `token68` | `ServerError::Malformed` | +| Rule | Source | Builder | Client | +|---|---|---|---| +| At least one auth-param | 6750 §3 | `ConfigError` | `ClientError` | +| `realm`, `scope`, `error`, `error_description`, `error_uri` at most once | 6750 §3 | by construction | `ClientError` | +| `resource_metadata` at most once | 9728 §5.1 | by construction | `ClientError` | +| `realm` is a quoted-string | 6750 §3 | `ConfigError` | parser | +| `scope` = `scope-token *( SP scope-token )`, `scope-token = 1*NQCHAR` | 6749 A.4 | `ConfigError` | `ClientError` | +| `error` = `1*NQSCHAR` | 6749 A.7 | `ErrorCode` only | `ClientError` | +| `error_description` = `1*NQSCHAR` | 6749 A.8 | `ConfigError` | `ClientError` | +| `error_uri` is an absolute `URI` | 6750 §3, 6749 A.9, 3986 §3 | `ConfigError` | `ClientError` | +| `resource_metadata` is an `https` `URI` with a host and no fragment | 9728 §1.2, §3, §5.1 | `ConfigError` | `ClientError` | +| Token is a `b64token` | 6750 §2.1 | — | `ClientError` | +| Credentials are `"Bearer" 1*SP b64token` | 6750 §2.1 | `ServerError::Malformed` | — | + +Unknown auth-params are ignored (RFC 6750 section 3). + +## Caller duties + +* Send a `Bearer` challenge on every 401 for a protected resource (RFC 6750 + section 3). +* Leave out `error` when the request had no credentials (RFC 6750 section + 3.1). +* Respond with `ErrorCode::status()`. # Testing -* RFC 6750 section 3 and RFC 9728 section 5.1 examples. -* Every challenge parameter parsed back with `parse_challenges`. -* One negative test per validation rule. +* RFC 6750 section 3 and RFC 9728 section 5.1 examples, on both sides. +* Builder output parsed back with `parse_challenges` and `Client`. +* `respond` output parsed back with `Credentials::parse`. +* One negative test per rule, on each side that checks it. diff --git a/tests/bearer.rs b/tests/bearer.rs index 43a16f4..777dddb 100644 --- a/tests/bearer.rs +++ b/tests/bearer.rs @@ -3,10 +3,13 @@ //! `Bearer` challenges and credentials against RFC 6750 and RFC 9728. -#![cfg(feature = "server")] +use std::convert::TryFrom as _; +use http_auth::bearer::{self, Client}; +#[cfg(feature = "server")] use http_auth::bearer::{ChallengeBuilder, Credentials, ErrorCode}; +#[cfg(feature = "server")] fn params(header: &str) -> Vec<(String, String)> { let c = http_auth::parse_challenges(header).unwrap(); assert_eq!(c.len(), 1); @@ -20,6 +23,7 @@ fn params(header: &str) -> Vec<(String, String)> { } } +#[cfg(feature = "server")] #[test] fn rfc6750_examples() { let c = ChallengeBuilder::new().realm("example").build().unwrap(); @@ -36,6 +40,7 @@ fn rfc6750_examples() { ); } +#[cfg(feature = "server")] #[test] fn rfc9728_example() { let c = ChallengeBuilder::new() @@ -48,6 +53,7 @@ fn rfc9728_example() { ); } +#[cfg(feature = "server")] #[test] fn round_trip() { let c = ChallengeBuilder::new() @@ -81,9 +87,9 @@ fn round_trip() { params(c.as_str()), vec![("error".to_owned(), "invalid_request".to_owned())] ); - assert_eq!(ChallengeBuilder::new().build().unwrap().as_str(), "Bearer"); } +#[cfg(feature = "server")] #[test] fn rejects_invalid_values() { assert!(ChallengeBuilder::new().realm("a\nb").build().is_err()); @@ -99,20 +105,78 @@ fn rejects_invalid_values() { .error_description("tab\there") .build() .is_err()); + assert!(ChallengeBuilder::new().build().is_err()); + assert!(ChallengeBuilder::new() + .error_description("") + .build() + .is_err()); assert!(ChallengeBuilder::new().error_uri("a\"b").build().is_err()); + assert!(ChallengeBuilder::new() + .error_uri("https://example.com/a b") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_uri("/relative") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_uri("https://example.com/%zz") + .build() + .is_err()); + assert!(ChallengeBuilder::new() + .error_uri("https://[::1/") + .build() + .is_err()); + for ok in [ + "https://example.com/errors#invalid_token", + "urn:example:error", + "http://[2001:db8::1]:8080/e?x=1", + ] { + assert!( + ChallengeBuilder::new().error_uri(ok).build().is_ok(), + "{}", + ok + ); + } assert!(ChallengeBuilder::new().scope("a b").build().is_err()); assert!(ChallengeBuilder::new().scope(" a").build().is_err()); assert!(ChallengeBuilder::new().scope("a ").build().is_err()); assert!(ChallengeBuilder::new().scope("").build().is_err()); assert!(ChallengeBuilder::new().scope("a\"b").build().is_err()); assert!(ChallengeBuilder::new().scope("caf\u{e9}").build().is_err()); - assert!(ChallengeBuilder::new() - .resource_metadata("x\"y") - .build() - .is_err()); + for bad in [ + "x\"y", + "http://example.com/.well-known/oauth-protected-resource", + "https://example.com/.well-known/oauth-protected-resource#f", + "https:///no-host", + "/.well-known/oauth-protected-resource", + ] { + assert!( + ChallengeBuilder::new() + .resource_metadata(bad) + .build() + .is_err(), + "{}", + bad + ); + } + for ok in [ + "https://example.com/.well-known/oauth-protected-resource", + "HTTPS://[::1]:8443/.well-known/oauth-protected-resource/api?x=1", + ] { + assert!( + ChallengeBuilder::new() + .resource_metadata(ok) + .build() + .is_ok(), + "{}", + ok + ); + } assert!(ChallengeBuilder::new().scope("a b").build().is_ok()); } +#[cfg(feature = "server")] #[test] fn error_status() { assert_eq!(ErrorCode::InvalidRequest.status(), 400); @@ -120,6 +184,7 @@ fn error_status() { assert_eq!(ErrorCode::InsufficientScope.status(), 403); } +#[cfg(feature = "server")] #[test] fn credentials() { let c = Credentials::parse("Bearer mF_9.B5f-4.1JqM") @@ -145,3 +210,111 @@ fn credentials() { Some(Err(http_auth::ServerError::Malformed(_))) )); } + +fn client(header: &str) -> Client { + let c = http_auth::parse_challenges(header).unwrap(); + assert_eq!(c.len(), 1); + Client::try_from(&c[0]).unwrap() +} + +#[test] +fn client_rfc6750_example() { + let c = client( + r#"Bearer realm="example", error="invalid_token", error_description="The access token expired""#, + ); + assert_eq!(c.realm(), Some("example")); + assert_eq!(c.error(), Some("invalid_token")); + assert_eq!(c.error_description(), Some("The access token expired")); + assert_eq!(c.scope(), None); + assert_eq!(c.error_uri(), None); + assert_eq!(c.resource_metadata(), None); +} + +#[test] +fn client_rfc9728_example() { + let c = client( + r#"Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource""#, + ); + assert_eq!( + c.resource_metadata(), + Some("https://resource.example.com/.well-known/oauth-protected-resource") + ); + assert_eq!(c.realm(), None); +} + +#[test] +fn client_parsing() { + let c = client(r#"Bearer REALM="a\"b", Scope=x, error=use_dpop_nonce, other="ignored""#); + assert_eq!(c.realm(), Some("a\"b")); + assert_eq!(c.scope(), Some("x")); + assert_eq!(c.error(), Some("use_dpop_nonce")); + + let wrong = http_auth::parse_challenges(r#"Basic realm="x""#).unwrap(); + assert!(Client::try_from(&wrong[0]).is_err()); + for bad in [ + "Bearer", + "Bearer abc=", + r#"Bearer realm="a", realm="b""#, + r#"Bearer scope="a", Scope="b""#, + r#"Bearer error="a", error="b""#, + r#"Bearer error_description="a", error_description="b""#, + r#"Bearer error_uri="https://a/", error_uri="https://b/""#, + r#"Bearer resource_metadata="https://a/", resource_metadata="https://b/""#, + r#"Bearer scope="a b""#, + r#"Bearer error="""#, + r#"Bearer error_description="""#, + r#"Bearer error="\\x""#, + r#"Bearer error_uri="relative""#, + r#"Bearer resource_metadata="http://example.com/m""#, + ] { + let c = http_auth::parse_challenges(bad).unwrap(); + assert!(Client::try_from(&c[0]).is_err(), "{}", bad); + } +} + +#[test] +fn client_respond() { + let c = client(r#"Bearer realm="example""#); + assert_eq!( + c.respond("mF_9.B5f-4.1JqM").unwrap(), + "Bearer mF_9.B5f-4.1JqM" + ); + assert_eq!( + bearer::encode_credentials("abc+/==").unwrap(), + "Bearer abc+/==" + ); + for bad in ["", "a b", "a\r\nX: y", "=abc", "a=b", "caf\u{e9}", "a\"b"] { + assert!(bearer::encode_credentials(bad).is_err(), "{:?}", bad); + assert!(c.respond(bad).is_err(), "{:?}", bad); + } +} + +#[cfg(feature = "server")] +#[test] +fn client_server_round_trip() { + let header = ChallengeBuilder::new() + .realm("zen \"ith\" \\") + .scope("live:read vod:read") + .error(ErrorCode::InsufficientScope) + .error_description("need vod:read") + .error_uri("https://example.com/e") + .resource_metadata("https://example.com/.well-known/oauth-protected-resource") + .build() + .unwrap(); + let c = client(header.as_str()); + assert_eq!(c.realm(), Some("zen \"ith\" \\")); + assert_eq!(c.scope(), Some("live:read vod:read")); + assert_eq!(c.error(), Some(ErrorCode::InsufficientScope.as_str())); + assert_eq!(c.error_description(), Some("need vod:read")); + assert_eq!(c.error_uri(), Some("https://example.com/e")); + assert_eq!( + c.resource_metadata(), + Some("https://example.com/.well-known/oauth-protected-resource") + ); + + for token in ["mF_9.B5f-4.1JqM", "abc+/==", "x"] { + let header = c.respond(token).unwrap(); + let creds = Credentials::parse(header.as_str()).unwrap().unwrap(); + assert_eq!(creds.token(), token); + } +} From 58c7f9c527fcfaefbb7569fa0430a2d32c9cfc9a Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Sat, 24 Oct 2026 00:13:08 +0100 Subject: [PATCH 3/4] Add Bearer client and full RFC 6750 validation bearer::Client reads Bearer challenges and bearer::encode_credentials sends the token. Client and ChallengeBuilder share one table of the RFC 6750 and RFC 9728 parameters and their rules. error_uri and resource_metadata are checked against the RFC 3986 URI grammar, fuzzed against iri-string. --- .github/workflows/fuzz.yml | 2 + CHANGELOG.md | 4 +- design/20261023-bearer.md | 1 + fuzz/Cargo.lock | 33 +- fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/bearer_uri.rs | 20 ++ fuzz/seeds/uris/ipv4-fallback | 1 + fuzz/seeds/uris/ipv4-mapped | 1 + fuzz/seeds/uris/ipv6 | 1 + fuzz/seeds/uris/ipvfuture | 1 + fuzz/seeds/uris/pct-encoded | 1 + fuzz/seeds/uris/relative | 1 + fuzz/seeds/uris/urn | 1 + fuzz/seeds/uris/userinfo-port-query-fragment | 1 + src/bearer.rs | 197 +++++++++++ src/bearer/server.rs | 80 +---- src/lib.rs | 4 +- src/parser.rs | 5 + src/render.rs | 11 +- src/server.rs | 1 + src/uri.rs | 351 +++++++++++++++++++ 21 files changed, 647 insertions(+), 77 deletions(-) create mode 100644 fuzz/fuzz_targets/bearer_uri.rs create mode 100644 fuzz/seeds/uris/ipv4-fallback create mode 100644 fuzz/seeds/uris/ipv4-mapped create mode 100644 fuzz/seeds/uris/ipv6 create mode 100644 fuzz/seeds/uris/ipvfuture create mode 100644 fuzz/seeds/uris/pct-encoded create mode 100644 fuzz/seeds/uris/relative create mode 100644 fuzz/seeds/uris/urn create mode 100644 fuzz/seeds/uris/userinfo-port-query-fragment create mode 100644 src/uri.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 960489f..baa94e7 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -38,6 +38,8 @@ jobs: seeds: challenges - target: parse_credentials seeds: credentials + - target: bearer_uri + seeds: uris - target: digest_response seeds: credentials env: diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd40f1..fdbb9a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,8 +52,8 @@ * `digest::Qop` now implements `PartialEq` and `Eq`. * `digest::QopSet` constructors: `From`, `BitOr`, `contains`, `is_empty`. -* `bearer::ChallengeBuilder`, `bearer::ErrorCode`, and `bearer::Credentials` - (server side). +* `bearer::Client`, `bearer::encode_credentials`, `bearer::ChallengeBuilder`, + `bearer::ErrorCode`, and `bearer::Credentials`. ### Changed diff --git a/design/20261023-bearer.md b/design/20261023-bearer.md index 5084381..e2742af 100644 --- a/design/20261023-bearer.md +++ b/design/20261023-bearer.md @@ -65,3 +65,4 @@ Unknown auth-params are ignored (RFC 6750 section 3). * Builder output parsed back with `parse_challenges` and `Client`. * `respond` output parsed back with `Credentials::parse`. * One negative test per rule, on each side that checks it. +* Fuzzing: `bearer_uri` compares the URI check with `iri-string`. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 80d8806..8142476 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -176,6 +176,7 @@ version = "0.0.0" dependencies = [ "env_logger", "http-auth", + "iri-string", "libfuzzer-sys", "log", "nom", @@ -187,6 +188,16 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "iri-string" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1663ee7d8cf2900cc1414b1e1eec9f348d6eaa3bcab07579f4726a4b8499f447" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "jobserver" version = "0.1.32" @@ -343,6 +354,26 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.8" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 1783f8b..d37faed 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -14,6 +14,7 @@ env_logger = "0.9" libfuzzer-sys = "0.4" log = "0.4" nom = "7.0" +iri-string = "0.7" [dependencies.http-auth] path = ".." @@ -35,6 +36,12 @@ path = "fuzz_targets/parse_credentials.rs" test = false doc = false +[[bin]] +name = "bearer_uri" +path = "fuzz_targets/bearer_uri.rs" +test = false +doc = false + [[bin]] name = "render_challenges" path = "fuzz_targets/render_challenges.rs" diff --git a/fuzz/fuzz_targets/bearer_uri.rs b/fuzz/fuzz_targets/bearer_uri.rs new file mode 100644 index 0000000..be08d7d --- /dev/null +++ b/fuzz/fuzz_targets/bearer_uri.rs @@ -0,0 +1,20 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Differential: http-auth's URI validator vs iri-string (RFC 3986 `URI`): +// $ cargo +nightly fuzz run bearer_uri + +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &str| { + let _ = env_logger::builder().try_init(); + let ours = http_auth::bearer::ChallengeBuilder::new() + .error_uri(data) + .build() + .is_ok(); + let oracle = iri_string::types::UriStr::new(data).is_ok(); + if ours != oracle { + panic!("MISMATCH {:?} ours={} oracle={}", data, ours, oracle); + } +}); diff --git a/fuzz/seeds/uris/ipv4-fallback b/fuzz/seeds/uris/ipv4-fallback new file mode 100644 index 0000000..1fc7780 --- /dev/null +++ b/fuzz/seeds/uris/ipv4-fallback @@ -0,0 +1 @@ +https://1.2.3.4abc/ \ No newline at end of file diff --git a/fuzz/seeds/uris/ipv4-mapped b/fuzz/seeds/uris/ipv4-mapped new file mode 100644 index 0000000..bb6189a --- /dev/null +++ b/fuzz/seeds/uris/ipv4-mapped @@ -0,0 +1 @@ +http://[::ffff:192.0.2.1]/ \ No newline at end of file diff --git a/fuzz/seeds/uris/ipv6 b/fuzz/seeds/uris/ipv6 new file mode 100644 index 0000000..d403375 --- /dev/null +++ b/fuzz/seeds/uris/ipv6 @@ -0,0 +1 @@ +http://[::1]:80/x?y#z \ No newline at end of file diff --git a/fuzz/seeds/uris/ipvfuture b/fuzz/seeds/uris/ipvfuture new file mode 100644 index 0000000..bff6332 --- /dev/null +++ b/fuzz/seeds/uris/ipvfuture @@ -0,0 +1 @@ +http://[v7.x:y]/ \ No newline at end of file diff --git a/fuzz/seeds/uris/pct-encoded b/fuzz/seeds/uris/pct-encoded new file mode 100644 index 0000000..1a24d17 --- /dev/null +++ b/fuzz/seeds/uris/pct-encoded @@ -0,0 +1 @@ +http://example.com/%20 \ No newline at end of file diff --git a/fuzz/seeds/uris/relative b/fuzz/seeds/uris/relative new file mode 100644 index 0000000..71dbab5 --- /dev/null +++ b/fuzz/seeds/uris/relative @@ -0,0 +1 @@ +scheme:foo \ No newline at end of file diff --git a/fuzz/seeds/uris/urn b/fuzz/seeds/uris/urn new file mode 100644 index 0000000..ff4716e --- /dev/null +++ b/fuzz/seeds/uris/urn @@ -0,0 +1 @@ +urn:oasis:names:specification:docbook:dtd:xml:4.1.2 \ No newline at end of file diff --git a/fuzz/seeds/uris/userinfo-port-query-fragment b/fuzz/seeds/uris/userinfo-port-query-fragment new file mode 100644 index 0000000..1e15e0c --- /dev/null +++ b/fuzz/seeds/uris/userinfo-port-query-fragment @@ -0,0 +1 @@ +http://user:pass@example.com:8080/a/b?c=d#frag \ No newline at end of file diff --git a/src/bearer.rs b/src/bearer.rs index 10b65dd..40d099b 100644 --- a/src/bearer.rs +++ b/src/bearer.rs @@ -4,9 +4,206 @@ //! `Bearer` authentication scheme, as in //! [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). +use std::convert::TryFrom; + +use crate::{ChallengeRef, ClientError}; + #[cfg(feature = "server")] mod server; #[cfg(feature = "server")] #[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub use self::server::{ChallengeBuilder, Credentials, ErrorCode}; + +use crate::render; +use crate::table::{char_classes, C_SCOPE_TOKEN}; +use crate::uri; + +pub(crate) const REALM: usize = 0; +pub(crate) const SCOPE: usize = 1; +pub(crate) const ERROR: usize = 2; +pub(crate) const ERROR_DESCRIPTION: usize = 3; +pub(crate) const ERROR_URI: usize = 4; +pub(crate) const RESOURCE_METADATA: usize = 5; + +/// Client for a `Bearer` challenge, as in +/// [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). +#[derive(Clone, Debug)] +pub struct Client { + params: Params, +} + +impl Client { + /// Returns the `realm`, if present. + pub fn realm(&self) -> Option<&str> { + self.params.get(REALM) + } + + /// Returns the `scope`, if present. + pub fn scope(&self) -> Option<&str> { + self.params.get(SCOPE) + } + + /// Returns the `error`, if present. + pub fn error(&self) -> Option<&str> { + self.params.get(ERROR) + } + + /// Returns the `error_description`, if present. + pub fn error_description(&self) -> Option<&str> { + self.params.get(ERROR_DESCRIPTION) + } + + /// Returns the `error_uri`, if present. + pub fn error_uri(&self) -> Option<&str> { + self.params.get(ERROR_URI) + } + + /// Returns the `resource_metadata`, if present. + pub fn resource_metadata(&self) -> Option<&str> { + self.params.get(RESOURCE_METADATA) + } + + /// Responds to the challenge with the supplied token. + pub fn respond(&self, token: &str) -> Result { + encode_credentials(token) + } +} + +impl TryFrom<&ChallengeRef<'_>> for Client { + type Error = ClientError; + + fn try_from(value: &ChallengeRef<'_>) -> Result { + if !value.scheme().eq_ignore_ascii_case("Bearer") { + return Err(ClientError::new(format!( + "bearer::Client doesn't support challenge scheme {:?}", + value.scheme() + ))); + } + let raw = match value.body() { + crate::AuthBodyRef::Params(p) => p, + crate::AuthBodyRef::Token68(_) => return Err(ClientError::new("expected auth-params")), + }; + let mut params = Params::new(); + for (name, val) in raw { + let value = val.to_unescaped().into_owned(); + let idx = PARAMS + .iter() + .position(|(n, _, _)| n.eq_ignore_ascii_case(name)); + let Some(idx) = idx else { + continue; + }; + if params.0[idx].is_some() { + return Err(ClientError::new("repeated auth-param")); + } + params.0[idx] = Some(value); + } + params.validate().map_err(ClientError::new)?; + Ok(Client { params }) + } +} + +/// Encodes the given token as a `Bearer` credential. +pub fn encode_credentials(token: &str) -> Result { + if !crate::parser::is_b64token(token) { + return Err(ClientError::new("token must be a b64token")); + } + Ok(format!("Bearer {}", token)) +} + +type Validator = fn(&str) -> bool; +type ParamSpec = (&'static str, Validator, &'static str); + +const PARAMS: [ParamSpec; 6] = [ + ("realm", render::is_quotable, "realm must be quotable"), + ( + "scope", + valid_scope, + "scope must be space-separated scope-tokens", + ), + ("error", valid_nqschar, "error must be printable ASCII"), + ( + "error_description", + valid_nqschar, + "error_description must be printable ASCII", + ), + ( + "error_uri", + valid_error_uri, + "error_uri must be an absolute URI", + ), + ( + "resource_metadata", + valid_resource_metadata, + "resource_metadata must be an https URI with a host and no fragment", + ), +]; + +#[derive(Clone, Debug)] +pub(crate) struct Params([Option; 6]); + +impl Params { + pub(crate) fn new() -> Self { + Self(std::array::from_fn(|_| None)) + } + + pub(crate) fn validate(&self) -> Result<(), &'static str> { + if self.0.iter().all(|v| v.is_none()) { + return Err("at least one auth-param is required"); + } + for (i, value) in self.0.iter().enumerate() { + if let Some(value) = value { + if !(PARAMS[i].1)(value) { + return Err(PARAMS[i].2); + } + } + } + Ok(()) + } + + pub(crate) fn get(&self, i: usize) -> Option<&str> { + self.0[i].as_deref() + } + + #[cfg(feature = "server")] + pub(crate) fn fields(&self) -> [(&'static str, Option<&str>); 6] { + let mut out = [("", None); 6]; + for (i, (name, _, _)) in PARAMS.iter().enumerate() { + out[i] = (name, self.0[i].as_deref()); + } + out + } + + #[cfg(feature = "server")] + pub(crate) fn set(&mut self, i: usize, value: String) { + self.0[i] = Some(value); + } +} + +pub(crate) fn valid_scope(s: &str) -> bool { + !s.is_empty() + && s.split(' ').all(|part| { + !part.is_empty() && part.bytes().all(|b| (char_classes(b) & C_SCOPE_TOKEN) != 0) + }) +} + +pub(crate) fn valid_nqschar(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b == b' ' || (char_classes(b) & C_SCOPE_TOKEN) != 0) +} + +pub(crate) fn valid_error_uri(s: &str) -> bool { + uri::parse(s).is_some() +} + +pub(crate) fn valid_resource_metadata(s: &str) -> bool { + match uri::parse(s) { + Some(u) => { + u.scheme.eq_ignore_ascii_case("https") + && u.host.map(|h| !h.is_empty()).unwrap_or(false) + && u.fragment.is_none() + } + None => false, + } +} diff --git a/src/bearer/server.rs b/src/bearer/server.rs index 1df2678..3e54f25 100644 --- a/src/bearer/server.rs +++ b/src/bearer/server.rs @@ -5,8 +5,7 @@ //! [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750). use crate::render::HeaderWriter; -use crate::server::{check_realm, Challenge, ConfigError, CredentialsSource, ServerError}; -use crate::table::{char_classes, C_SCOPE_TOKEN}; +use crate::server::{Challenge, ConfigError, CredentialsSource, ServerError}; use crate::AuthBodyRef; /// An `error` value for a `Bearer` challenge, as in @@ -59,102 +58,64 @@ impl std::fmt::Display for ErrorCode { /// ``` #[derive(Clone, Debug)] pub struct ChallengeBuilder { - realm: Option, - scope: Option, - error: Option, - error_description: Option, - error_uri: Option, - resource_metadata: Option, + params: crate::bearer::Params, } impl ChallengeBuilder { /// Starts building a challenge with no parameters. pub fn new() -> Self { Self { - realm: None, - scope: None, - error: None, - error_description: None, - error_uri: None, - resource_metadata: None, + params: crate::bearer::Params::new(), } } /// Sets the `realm`. pub fn realm(mut self, realm: impl Into) -> Self { - self.realm = Some(realm.into()); + self.params.set(crate::bearer::REALM, realm.into()); self } /// Sets the `scope`. pub fn scope(mut self, scope: impl Into) -> Self { - self.scope = Some(scope.into()); + self.params.set(crate::bearer::SCOPE, scope.into()); self } /// Sets the `error`. pub fn error(mut self, error: ErrorCode) -> Self { - self.error = Some(error); + self.params + .set(crate::bearer::ERROR, error.as_str().to_owned()); self } /// Sets the `error_description`. pub fn error_description(mut self, error_description: impl Into) -> Self { - self.error_description = Some(error_description.into()); + self.params + .set(crate::bearer::ERROR_DESCRIPTION, error_description.into()); self } /// Sets the `error_uri`. pub fn error_uri(mut self, error_uri: impl Into) -> Self { - self.error_uri = Some(error_uri.into()); + self.params.set(crate::bearer::ERROR_URI, error_uri.into()); self } /// Sets the `resource_metadata`. pub fn resource_metadata(mut self, resource_metadata: impl Into) -> Self { - self.resource_metadata = Some(resource_metadata.into()); + self.params + .set(crate::bearer::RESOURCE_METADATA, resource_metadata.into()); self } /// Returns the challenge, or [`ConfigError`] for an invalid value. pub fn build(self) -> Result { - if let Some(realm) = &self.realm { - check_realm(realm)?; - } - if let Some(scope) = &self.scope { - if !valid_scope(scope) { - return Err(ConfigError("scope must be space-separated scope-tokens")); - } - } - let values = [ - self.error_description.as_deref(), - self.error_uri.as_deref(), - self.resource_metadata.as_deref(), - ]; - for value in values.iter().filter_map(|v| *v) { - if !crate::render::is_bearer_param(value) { - return Err(ConfigError("must be printable ASCII without \" or \\")); - } - } - + self.params.validate().map_err(ConfigError)?; let mut w = HeaderWriter::new("Bearer"); - if let Some(realm) = &self.realm { - w.quoted("realm", realm); - } - if let Some(scope) = &self.scope { - w.quoted("scope", scope); - } - if let Some(error) = &self.error { - w.quoted("error", error.as_str()); - } - if let Some(error_description) = &self.error_description { - w.quoted("error_description", error_description); - } - if let Some(error_uri) = &self.error_uri { - w.quoted("error_uri", error_uri); - } - if let Some(resource_metadata) = &self.resource_metadata { - w.quoted("resource_metadata", resource_metadata); + for (name, value) in self.params.fields() { + if let Some(value) = value { + w.quoted(name, value); + } } Ok(Challenge(w.finish())) } @@ -166,13 +127,6 @@ impl Default for ChallengeBuilder { } } -fn valid_scope(s: &str) -> bool { - !s.is_empty() - && s.split(' ').all(|part| { - !part.is_empty() && part.bytes().all(|b| (char_classes(b) & C_SCOPE_TOKEN) != 0) - }) -} - /// Credentials from a `Bearer` `Authorization` or `Proxy-Authorization` header. #[derive(Clone)] pub struct Credentials { diff --git a/src/lib.rs b/src/lib.rs index ae74ac0..0c69d3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,16 +93,14 @@ pub mod basic; #[cfg_attr(docsrs, doc(cfg(feature = "digest-scheme")))] pub mod digest; -#[cfg(feature = "server")] -#[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub mod bearer; mod table; +mod uri; #[cfg(feature = "server")] mod server; -#[cfg(any(feature = "digest-scheme", feature = "server"))] mod render; pub use parser::{parse_credentials, ChallengeParser}; diff --git a/src/parser.rs b/src/parser.rs index c276c36..dc44394 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -749,6 +749,11 @@ fn token68_end(input: &str, start: usize) -> Option { (after.is_empty() || after.starts_with(',')).then_some(end) } +/// Returns true if `s` is a complete `b64token` (RFC 6750 section 2.1). +pub(crate) fn is_b64token(s: &str) -> bool { + token68_end(s, 0) == Some(s.len()) +} + /// Returns the value of a hex digit. #[cfg(all(feature = "server", feature = "digest-scheme"))] fn hex_val(b: u8) -> Option { diff --git a/src/render.rs b/src/render.rs index b3163ef..9aedb67 100644 --- a/src/render.rs +++ b/src/render.rs @@ -3,6 +3,7 @@ //! Writes `scheme k=v, k="v", k*=UTF-8''v` header values. +#[cfg(any(feature = "digest-scheme", feature = "server"))] use crate::is_token; #[cfg(feature = "digest-scheme")] use crate::table::C_ATTR; @@ -14,20 +15,14 @@ pub(crate) fn is_quotable(s: &str) -> bool { .all(|b| (char_classes(b) & (C_QDTEXT | C_ESCAPABLE)) != 0) } -/// Returns true if `s` consists only of bytes allowed in `Bearer` -/// `error_description`, `error_uri`, and `resource_metadata` values. -#[cfg(feature = "server")] -pub(crate) fn is_bearer_param(s: &str) -> bool { - s.bytes() - .all(|b| b == b' ' || (char_classes(b) & crate::table::C_SCOPE_TOKEN) != 0) -} - /// Writes a challenge or credentials header value. Callers validate values. +#[cfg(any(feature = "digest-scheme", feature = "server"))] pub(crate) struct HeaderWriter { out: String, has_params: bool, } +#[cfg(any(feature = "digest-scheme", feature = "server"))] impl HeaderWriter { pub(crate) fn new(scheme: &str) -> Self { debug_assert!(is_token(scheme)); diff --git a/src/server.rs b/src/server.rs index 75f03b6..343b98d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -256,6 +256,7 @@ impl<'i> sealed::Sealed<'i> for &'i http::HeaderValue { } /// Validates a realm value. +#[cfg(any(feature = "basic-scheme", feature = "digest-scheme"))] pub(crate) fn check_realm(realm: &str) -> Result<(), ConfigError> { if crate::render::is_quotable(realm) { Ok(()) diff --git a/src/uri.rs b/src/uri.rs new file mode 100644 index 0000000..0f22577 --- /dev/null +++ b/src/uri.rs @@ -0,0 +1,351 @@ +// Copyright (C) 2026 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +pub(crate) struct Uri<'a> { + pub(crate) scheme: &'a str, + pub(crate) host: Option<&'a str>, + pub(crate) fragment: Option<&'a str>, +} + +pub(crate) fn parse(input: &str) -> Option> { + let bytes = input.as_bytes(); + (!bytes.is_empty() && bytes[0].is_ascii_alphabetic()).then_some(())?; + let mut position = 1 + bytes[1..] + .iter() + .take_while(|&&b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) + .count(); + let scheme_end = position; + (position < bytes.len() && bytes[position] == b':').then_some(())?; + position += 1; + let mut host_range = None; + if position + 1 < bytes.len() && bytes[position] == b'/' && bytes[position + 1] == b'/' { + position += 2; + let authority_end = memchr::memchr3(b'/', b'?', b'#', &bytes[position..]) + .map(|i| position + i) + .unwrap_or(bytes.len()); + if let Some(at) = bytes[position..authority_end] + .iter() + .position(|&c| c == b'@') + { + scan(&bytes[position..position + at], userinfo_byte).then_some(())?; + position += at + 1; + } + let host_start = position; + if position < authority_end && bytes[position] == b'[' { + let inner_start = position + 1; + let close = inner_start + memchr::memchr(b']', &bytes[inner_start..authority_end])?; + (is_ipv6(&bytes[inner_start..close]) || is_ipvfuture(&bytes[inner_start..close])) + .then_some(())?; + position = close + 1; + } else { + let cursor = ipv4(bytes, position); + let boundary = cursor.is_some_and(|value| { + value == authority_end + || matches!(bytes.get(value), Some(b':' | b'/' | b'?' | b'#')) + }); + if boundary { + position = cursor.unwrap(); + } else { + let mut local = 0; + skip(&bytes[position..authority_end], &mut local, |b| { + pchar_byte(b) && b != b':' && b != b'@' + }); + position += local; + } + } + host_range = Some((host_start, position)); + if position < authority_end && bytes[position] == b':' { + position += 1; + position += bytes[position..authority_end] + .iter() + .take_while(|&&b| b.is_ascii_digit()) + .count(); + } + (position == authority_end).then_some(())?; + path_segments(bytes, &mut position); + } else if position < bytes.len() && bytes[position] == b'/' { + position += 1; + if position < bytes.len() && !matches!(bytes[position], b'?' | b'#' | b'/') { + segment_nz(bytes, &mut position).then_some(())?; + } + path_segments(bytes, &mut position); + } else if position < bytes.len() && !matches!(bytes[position], b'?' | b'#') { + segment_nz(bytes, &mut position).then_some(())?; + path_segments(bytes, &mut position); + } + let mut fragment_range = None; + if position < bytes.len() && bytes[position] == b'?' { + position += 1; + skip(bytes, &mut position, query_byte); + } + if position < bytes.len() && bytes[position] == b'#' { + let fragment_start = position + 1; + position += 1; + skip(bytes, &mut position, query_byte); + fragment_range = Some((fragment_start, position)); + } + (position == bytes.len()).then_some(())?; + Some(Uri { + scheme: &input[..scheme_end], + host: host_range.map(|(a, b)| &input[a..b]), + fragment: fragment_range.map(|(a, b)| &input[a..b]), + }) +} + +fn skip(bytes: &[u8], position: &mut usize, allowed: fn(u8) -> bool) { + while *position < bytes.len() { + if allowed(bytes[*position]) { + *position += 1; + } else if bytes[*position] == b'%' + && *position + 2 < bytes.len() + && bytes[*position + 1].is_ascii_hexdigit() + && bytes[*position + 2].is_ascii_hexdigit() + { + *position += 3; + } else { + break; + } + } +} + +fn scan(bytes: &[u8], allowed: fn(u8) -> bool) -> bool { + let mut position = 0; + skip(bytes, &mut position, allowed); + position == bytes.len() +} + +fn segment_nz(bytes: &[u8], position: &mut usize) -> bool { + let start = *position; + skip(bytes, position, pchar_byte); + *position > start +} + +fn path_segments(bytes: &[u8], position: &mut usize) { + while *position < bytes.len() && bytes[*position] == b'/' { + *position += 1; + skip(bytes, position, pchar_byte); + } +} + +fn ipv4(bytes: &[u8], position: usize) -> Option { + let mut pos = position; + for i in 0..4 { + if i > 0 { + (pos < bytes.len() && bytes[pos] == b'.').then_some(())?; + pos += 1; + } + let start = pos; + pos += bytes[pos..] + .iter() + .take_while(|&&b| b.is_ascii_digit()) + .count(); + (pos > start && valid_dec_octet(&bytes[start..pos])).then_some(())?; + } + Some(pos) +} + +fn is_ipv6(bytes: &[u8]) -> bool { + let double_colon = bytes.windows(2).position(|window| *window == *b"::"); + if let Some(position) = double_colon { + if bytes[position + 2..] + .windows(2) + .any(|window| *window == *b"::") + { + return false; + } + } + let (left, right) = double_colon + .map(|position| (&bytes[..position], &bytes[position + 2..])) + .unwrap_or((bytes, &bytes[0..0])); + let Some(left_count) = validate_groups(left, double_colon.is_none()) else { + return false; + }; + let Some(right_count) = validate_groups(right, true) else { + return false; + }; + if double_colon.is_some() { + left_count + right_count <= 7 + } else { + left_count == 8 + } +} + +fn validate_groups(bytes: &[u8], allow_ipv4_last: bool) -> Option { + let mut position = 0; + let mut count = 0; + while position < bytes.len() { + let start = position; + while position < bytes.len() && bytes[position] != b':' { + position += 1; + } + let group = &bytes[start..position]; + (!group.is_empty()).then_some(())?; + if allow_ipv4_last && position == bytes.len() && group.contains(&b'.') { + (ipv4(group, 0)? == group.len()).then_some(())?; + count += 2; + } else { + (group.len() <= 4 && group.iter().all(|b| b.is_ascii_hexdigit())).then_some(())?; + count += 1; + } + if position < bytes.len() { + (bytes[position] == b':').then_some(())?; + position += 1; + (position < bytes.len() && bytes[position] != b':').then_some(())?; + } + } + Some(count) +} + +fn is_ipvfuture(bytes: &[u8]) -> bool { + if bytes.is_empty() || !(bytes[0] == b'v' || bytes[0] == b'V') { + return false; + } + let hex_len = bytes[1..] + .iter() + .take_while(|&&b| b.is_ascii_hexdigit()) + .count(); + if hex_len == 0 + || hex_len + 1 >= bytes.len() + || bytes[hex_len + 1] != b'.' + || hex_len + 2 >= bytes.len() + { + return false; + } + bytes[hex_len + 2..].iter().all(|&b| userinfo_byte(b)) +} + +fn valid_dec_octet(bytes: &[u8]) -> bool { + !bytes.is_empty() + && bytes.len() <= 3 + && (bytes.len() == 1 || bytes[0] != b'0') + && bytes.iter().all(u8::is_ascii_digit) + && bytes.iter().fold(0u16, |v, &b| v * 10 + (b - b'0') as u16) <= 255 +} + +fn pchar_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() + || b"-._~".contains(&b) + || b"!$&'()*+,;=".contains(&b) + || b == b':' + || b == b'@' +} +fn userinfo_byte(b: u8) -> bool { + pchar_byte(b) && b != b'@' +} +fn query_byte(b: u8) -> bool { + pchar_byte(b) || b == b'/' || b == b'?' +} + +#[cfg(test)] +mod tests { + use super::parse; + + #[test] + fn rfc3986_examples() { + for value in [ + "https://www.example.com/foo/bar?x=1#frag", + "ftp://ftp.is.co.za/rfc/rfc1808.txt", + "http://www.ietf.org/rfc/rfc2396.txt#header1", + "ldap://[2001:db8::7]/c=GB?objectClass?one", + "mailto:John.Doe@example.com", + "news:comp.infosystems.www.servers.unix", + "tel:+1-816-555-1212", + "telnet://192.0.2.16:80/", + "urn:oasis:names:specification:docbook:dtd:xml:4.1.2", + ] { + assert!(parse(value).is_some(), "{}", value); + } + } + + #[test] + fn invalid_ipv6() { + for value in [ + "http://[::1", + "http://[::1/", + "http://[2001:db8::g]/", + "http://[1.2.3.4]/", + ] { + assert!(parse(value).is_none(), "{}", value); + } + } + + #[test] + fn uri_forms() { + assert!(parse("http://example.com").is_some()); + assert!(parse("http://example.com/").is_some()); + assert!(parse("http://example.com/a/b").is_some()); + assert!(parse("http://example.com/a/b?c=d&e=f").is_some()); + assert!(parse("http://example.com/a/b#c").is_some()); + assert!(parse("scheme:foo").is_some()); + assert!(parse("scheme:").is_some()); + assert!(parse("").is_none()); + assert!(parse("http://").is_some()); + assert!(parse("http:///host").is_some()); + assert!(parse("http://[::1]:8080/x").is_some()); + assert!(parse("http://[v1.fe80::a]/").is_some()); + assert!(parse("http://user:pass@example.com:80/x").is_some()); + assert!(parse("http://example.com/%zz").is_none()); + assert!(parse("http://example.com/%20").is_some()); + } + + #[test] + fn ipv6_alternatives() { + for value in [ + "http://[1:2:3:4:5:6:7:8]/", + "http://[::1:2:3:4:5:6:7]/", + "http://[1::2:3:4:5:6:7]/", + "http://[1:2::3:4:5:6:7]/", + "http://[1::2:3:4:5]/", + "http://[1::2:3:4]/", + "http://[1::2:3]/", + "http://[1::2]/", + "http://[1::]/", + "http://[::1]/", + "http://[::ffff:192.0.2.1]/", + ] { + assert!(parse(value).is_some(), "{}", value); + } + } + + #[test] + fn ipv6_invalid() { + for value in [ + "http://[:::]/", + "http://[1:2:3:4:5:6:7:8:9]/", + "http://[1::2::3]/", + "http://[12345::]/", + "http://[::1.2.3]/", + "http://[1.2.3.4::]/", + "http://[::ffff:1.2.3.256]/", + ] { + assert!(parse(value).is_none(), "{}", value); + } + } + + #[test] + fn host_fallback() { + for value in [ + "https://1.2.3.4abc/", + "https://1.2.3.4.5/", + "https://999.1.1.1/", + ] { + assert!(parse(value).is_some(), "{}", value); + } + } + + #[test] + fn ipv6_trailing_colon() { + for value in [ + "http://[1:2:3:4:5:6:7:8:]/", + "http://[::1:]:80/", + "http://[1::3:4:5:6:7:]/", + ] { + assert!(parse(value).is_none(), "{}", value); + } + } + + #[test] + fn ipvfuture_pct_encoded() { + assert!(parse("http://[v7.x%97:y]/").is_none()); + } +} From 7f39e506469a3f551735dabaada2d966eafae60c Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Sat, 24 Oct 2026 00:18:06 +0100 Subject: [PATCH 4/4] Keep fuzz/Cargo.lock at version 3 for Rust 1.70 --- fuzz/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 8142476..144d65a 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "aho-corasick"