Skip to content

Commit dec66ae

Browse files
authored
Merge pull request #11350 from Turbo87/beware-of-the-bears
Allow empty and `Bearer` auth schemes in `Authorization` headers
2 parents da194dd + cd28aa3 commit dec66ae

File tree

9 files changed

+220
-108
lines changed

9 files changed

+220
-108
lines changed

crates/crates_io_trustpub/src/access_token.rs

Lines changed: 39 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use rand::distr::{Alphanumeric, SampleString};
22
use secrecy::{ExposeSecret, SecretString};
33
use sha2::digest::Output;
44
use sha2::{Digest, Sha256};
5+
use std::str::FromStr;
56

67
/// A temporary access token used to publish crates to crates.io using
78
/// the "Trusted Publishing" feature.
@@ -29,19 +30,37 @@ impl AccessToken {
2930
Self(raw.into())
3031
}
3132

32-
/// Parse a byte string into an access token.
33+
/// Wrap the raw access token with the token prefix and a checksum.
3334
///
34-
/// This can be used to convert an HTTP header value into an access token.
35-
pub fn from_byte_str(byte_str: &[u8]) -> Result<Self, AccessTokenError> {
36-
let suffix = byte_str
37-
.strip_prefix(Self::PREFIX.as_bytes())
35+
/// This turns e.g. `ABC` into `cio_tp_ABC{checksum}`.
36+
pub fn finalize(&self) -> SecretString {
37+
let raw = self.0.expose_secret();
38+
let checksum = checksum(raw.as_bytes());
39+
format!("{}{raw}{checksum}", Self::PREFIX).into()
40+
}
41+
42+
/// Generate a SHA256 hash of the access token.
43+
///
44+
/// This is used to create a hashed version of the token for storage in
45+
/// the database to avoid storing the plaintext token.
46+
pub fn sha256(&self) -> Output<Sha256> {
47+
Sha256::digest(self.0.expose_secret())
48+
}
49+
}
50+
51+
impl FromStr for AccessToken {
52+
type Err = AccessTokenError;
53+
54+
/// Parse a string into an access token.
55+
fn from_str(s: &str) -> Result<Self, Self::Err> {
56+
let suffix = s
57+
.strip_prefix(Self::PREFIX)
3858
.ok_or(AccessTokenError::MissingPrefix)?;
3959

4060
if suffix.len() != Self::RAW_LENGTH + 1 {
4161
return Err(AccessTokenError::InvalidLength);
4262
}
4363

44-
let suffix = std::str::from_utf8(suffix).map_err(|_| AccessTokenError::InvalidCharacter)?;
4564
if !suffix.chars().all(|c| char::is_ascii_alphanumeric(&c)) {
4665
return Err(AccessTokenError::InvalidCharacter);
4766
}
@@ -58,23 +77,6 @@ impl AccessToken {
5877

5978
Ok(Self(raw.into()))
6079
}
61-
62-
/// Wrap the raw access token with the token prefix and a checksum.
63-
///
64-
/// This turns e.g. `ABC` into `cio_tp_ABC{checksum}`.
65-
pub fn finalize(&self) -> SecretString {
66-
let raw = self.0.expose_secret();
67-
let checksum = checksum(raw.as_bytes());
68-
format!("{}{raw}{checksum}", Self::PREFIX).into()
69-
}
70-
71-
/// Generate a SHA256 hash of the access token.
72-
///
73-
/// This is used to create a hashed version of the token for storage in
74-
/// the database to avoid storing the plaintext token.
75-
pub fn sha256(&self) -> Output<Sha256> {
76-
Sha256::digest(self.0.expose_secret())
77-
}
7880
}
7981

8082
/// The error type for parsing access tokens.
@@ -129,42 +131,33 @@ mod tests {
129131
}
130132

131133
#[test]
132-
fn test_from_byte_str() {
134+
fn test_from_str() {
133135
let token = AccessToken::generate().finalize();
134136
let token = token.expose_secret();
135-
let token2 = assert_ok!(AccessToken::from_byte_str(token.as_bytes()));
137+
let token2 = assert_ok!(token.parse::<AccessToken>());
136138
assert_eq!(token2.finalize().expose_secret(), token);
137139

138-
let bytes = b"cio_tp_0000000000000000000000000000000w";
139-
assert_ok!(AccessToken::from_byte_str(bytes));
140+
let str = "cio_tp_0000000000000000000000000000000w";
141+
assert_ok!(str.parse::<AccessToken>());
140142

141-
let bytes = b"invalid_token";
142-
assert_err_eq!(
143-
AccessToken::from_byte_str(bytes),
144-
AccessTokenError::MissingPrefix
145-
);
143+
let str = "invalid_token";
144+
assert_err_eq!(str.parse::<AccessToken>(), AccessTokenError::MissingPrefix);
146145

147-
let bytes = b"cio_tp_invalid_token";
148-
assert_err_eq!(
149-
AccessToken::from_byte_str(bytes),
150-
AccessTokenError::InvalidLength
151-
);
146+
let str = "cio_tp_invalid_token";
147+
assert_err_eq!(str.parse::<AccessToken>(), AccessTokenError::InvalidLength);
152148

153-
let bytes = b"cio_tp_00000000000000000000000000";
154-
assert_err_eq!(
155-
AccessToken::from_byte_str(bytes),
156-
AccessTokenError::InvalidLength
157-
);
149+
let str = "cio_tp_00000000000000000000000000";
150+
assert_err_eq!(str.parse::<AccessToken>(), AccessTokenError::InvalidLength);
158151

159-
let bytes = b"cio_tp_000000@0000000000000000000000000";
152+
let str = "cio_tp_000000@0000000000000000000000000";
160153
assert_err_eq!(
161-
AccessToken::from_byte_str(bytes),
154+
str.parse::<AccessToken>(),
162155
AccessTokenError::InvalidCharacter
163156
);
164157

165-
let bytes = b"cio_tp_00000000000000000000000000000000";
158+
let str = "cio_tp_00000000000000000000000000000000";
166159
assert_err_eq!(
167-
AccessToken::from_byte_str(bytes),
160+
str.parse::<AccessToken>(),
168161
AccessTokenError::InvalidChecksum {
169162
claimed: '0',
170163
actual: 'w',

src/auth.rs

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,63 @@ use crate::middleware::log_request::RequestLogExt;
44
use crate::models::token::{CrateScope, EndpointScope};
55
use crate::models::{ApiToken, User};
66
use crate::util::errors::{
7-
AppResult, InsecurelyGeneratedTokenRevoked, account_locked, forbidden, internal,
7+
AppResult, BoxedAppError, InsecurelyGeneratedTokenRevoked, account_locked, custom, forbidden,
8+
internal,
89
};
910
use crate::util::token::HashedToken;
11+
use axum::extract::FromRequestParts;
1012
use chrono::Utc;
1113
use crates_io_session::SessionExtension;
1214
use diesel_async::AsyncPgConnection;
13-
use http::header;
1415
use http::request::Parts;
16+
use http::{StatusCode, header};
17+
use secrecy::{ExposeSecret, SecretString};
18+
19+
pub struct AuthHeader(SecretString);
20+
21+
impl AuthHeader {
22+
pub async fn optional_from_request_parts(parts: &Parts) -> Result<Option<Self>, BoxedAppError> {
23+
let Some(auth_header) = parts.headers.get(header::AUTHORIZATION) else {
24+
return Ok(None);
25+
};
26+
27+
let auth_header = auth_header.to_str().map_err(|_| {
28+
let message = "Invalid `Authorization` header: Found unexpected non-ASCII characters";
29+
custom(StatusCode::UNAUTHORIZED, message)
30+
})?;
31+
32+
let (scheme, token) = auth_header.split_once(' ').unwrap_or(("", auth_header));
33+
if !(scheme.eq_ignore_ascii_case("Bearer") || scheme.is_empty()) {
34+
let message = format!(
35+
"Invalid `Authorization` header: Found unexpected authentication scheme `{scheme}`"
36+
);
37+
return Err(custom(StatusCode::UNAUTHORIZED, message));
38+
}
39+
40+
let token = SecretString::from(token.trim_ascii());
41+
Ok(Some(AuthHeader(token)))
42+
}
43+
44+
pub async fn from_request_parts(parts: &Parts) -> Result<Self, BoxedAppError> {
45+
let auth = Self::optional_from_request_parts(parts).await?;
46+
auth.ok_or_else(|| {
47+
let message = "Missing `Authorization` header";
48+
custom(StatusCode::UNAUTHORIZED, message)
49+
})
50+
}
51+
52+
pub fn token(&self) -> &SecretString {
53+
&self.0
54+
}
55+
}
56+
57+
impl<S: Send + Sync> FromRequestParts<S> for AuthHeader {
58+
type Rejection = BoxedAppError;
59+
60+
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
61+
Self::from_request_parts(parts).await
62+
}
63+
}
1564

1665
#[derive(Debug, Clone)]
1766
pub struct AuthCheck {
@@ -203,17 +252,12 @@ async fn authenticate_via_token(
203252
parts: &Parts,
204253
conn: &mut AsyncPgConnection,
205254
) -> AppResult<Option<TokenAuthentication>> {
206-
let maybe_authorization = parts
207-
.headers()
208-
.get(header::AUTHORIZATION)
209-
.and_then(|h| h.to_str().ok());
210-
211-
let Some(header_value) = maybe_authorization else {
255+
let Some(auth_header) = AuthHeader::optional_from_request_parts(parts).await? else {
212256
return Ok(None);
213257
};
214258

215-
let token =
216-
HashedToken::parse(header_value).map_err(|_| InsecurelyGeneratedTokenRevoked::boxed())?;
259+
let token = auth_header.token().expose_secret();
260+
let token = HashedToken::parse(token).map_err(|_| InsecurelyGeneratedTokenRevoked::boxed())?;
217261

218262
let token = ApiToken::find_by_api_token(conn, &token)
219263
.await

src/controllers/krate/publish.rs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Functionality related to publishing a new crate or version of a crate.
22
33
use crate::app::AppState;
4-
use crate::auth::{AuthCheck, Authentication};
4+
use crate::auth::{AuthCheck, AuthHeader, Authentication};
55
use crate::worker::jobs::{
66
self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion,
77
};
@@ -19,8 +19,9 @@ use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
1919
use futures_util::TryFutureExt;
2020
use futures_util::TryStreamExt;
2121
use hex::ToHex;
22+
use http::StatusCode;
2223
use http::request::Parts;
23-
use http::{StatusCode, header};
24+
use secrecy::ExposeSecret;
2425
use sha2::{Digest, Sha256};
2526
use std::collections::HashMap;
2627
use tokio::io::{AsyncRead, AsyncReadExt};
@@ -146,21 +147,20 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
146147
.await
147148
.optional()?;
148149

149-
// Trusted publishing tokens are distinguished from regular crates.io API
150-
// tokens because they use the `Bearer` auth scheme, so we look for that
151-
// specific prefix.
152-
let trustpub_token = req
153-
.headers
154-
.get(header::AUTHORIZATION)
155-
.and_then(|h| {
156-
let mut split = h.as_bytes().splitn(2, |b| *b == b' ');
157-
Some((split.next()?, split.next()?))
150+
let auth_header = AuthHeader::optional_from_request_parts(&req).await?;
151+
let trustpub_token = auth_header
152+
.and_then(|auth| {
153+
let token = auth.token().expose_secret();
154+
if !token.starts_with(AccessToken::PREFIX) {
155+
return None;
156+
}
157+
158+
Some(token.parse::<AccessToken>().map_err(|_| {
159+
let message = "Invalid `Authorization` header: Failed to parse token";
160+
custom(StatusCode::UNAUTHORIZED, message)
161+
}))
158162
})
159-
.filter(|(scheme, _token)| scheme.eq_ignore_ascii_case(b"Bearer"))
160-
.map(|(_scheme, token)| token.trim_ascii())
161-
.map(AccessToken::from_byte_str)
162-
.transpose()
163-
.map_err(|_| forbidden("Invalid authentication token"))?;
163+
.transpose()?;
164164

165165
let auth = if let Some(trustpub_token) = trustpub_token {
166166
let Some(existing_crate) = &existing_crate else {

src/controllers/trustpub/tokens/exchange/tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ async fn test_happy_path() -> anyhow::Result<()> {
8383
"#);
8484

8585
let token = json["token"].as_str().unwrap();
86-
let token = assert_ok!(AccessToken::from_byte_str(token.as_bytes()));
86+
let token = assert_ok!(token.parse::<AccessToken>());
8787
let hashed_token = token.sha256();
8888

8989
let mut conn = client.app().db_conn().await;

src/controllers/trustpub/tokens/revoke/mod.rs

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use crate::app::AppState;
2+
use crate::auth::AuthHeader;
23
use crate::util::errors::{AppResult, custom};
34
use crates_io_database::schema::trustpub_tokens;
45
use crates_io_trustpub::access_token::AccessToken;
56
use diesel::prelude::*;
67
use diesel_async::RunQueryDsl;
7-
use http::{HeaderMap, StatusCode, header};
8+
use http::StatusCode;
9+
use secrecy::ExposeSecret;
810

911
#[cfg(test)]
1012
mod tests;
@@ -20,19 +22,10 @@ mod tests;
2022
tag = "trusted_publishing",
2123
responses((status = 204, description = "Successful Response")),
2224
)]
23-
pub async fn revoke_trustpub_token(app: AppState, headers: HeaderMap) -> AppResult<StatusCode> {
24-
let Some(auth_header) = headers.get(header::AUTHORIZATION) else {
25-
let message = "Missing authorization header";
26-
return Err(custom(StatusCode::UNAUTHORIZED, message));
27-
};
28-
29-
let Some(bearer) = auth_header.as_bytes().strip_prefix(b"Bearer ") else {
30-
let message = "Invalid authorization header";
31-
return Err(custom(StatusCode::UNAUTHORIZED, message));
32-
};
33-
34-
let Ok(token) = AccessToken::from_byte_str(bearer) else {
35-
let message = "Invalid authorization header";
25+
pub async fn revoke_trustpub_token(app: AppState, auth: AuthHeader) -> AppResult<StatusCode> {
26+
let token = auth.token().expose_secret();
27+
let Ok(token) = token.parse::<AccessToken>() else {
28+
let message = "Invalid `Authorization` header: Failed to parse token";
3629
return Err(custom(StatusCode::UNAUTHORIZED, message));
3730
};
3831

src/controllers/trustpub/tokens/revoke/tests.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,34 @@ async fn test_happy_path() -> anyhow::Result<()> {
6161
Ok(())
6262
}
6363

64+
#[tokio::test(flavor = "multi_thread")]
65+
async fn test_happy_path_without_auth_scheme() -> anyhow::Result<()> {
66+
let (app, _client) = TestApp::full().empty().await;
67+
let mut conn = app.db_conn().await;
68+
69+
let token1 = new_token(&mut conn, 1).await?;
70+
let _token2 = new_token(&mut conn, 2).await?;
71+
assert_compact_debug_snapshot!(all_crate_ids(&mut conn).await?, @"[[Some(1)], [Some(2)]]");
72+
73+
let token_client = MockTokenUser::with_auth_header(token1, app.clone());
74+
75+
let response = token_client.delete::<()>(URL).await;
76+
assert_snapshot!(response.status(), @"204 No Content");
77+
assert_eq!(response.text(), "");
78+
79+
// Check that the token is deleted
80+
assert_compact_debug_snapshot!(all_crate_ids(&mut conn).await?, @"[[Some(2)]]");
81+
82+
Ok(())
83+
}
84+
6485
#[tokio::test(flavor = "multi_thread")]
6586
async fn test_missing_authorization_header() -> anyhow::Result<()> {
6687
let (_app, client) = TestApp::full().empty().await;
6788

6889
let response = client.delete::<()>(URL).await;
6990
assert_snapshot!(response.status(), @"401 Unauthorized");
70-
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Missing authorization header"}]}"#);
91+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Missing `Authorization` header"}]}"#);
7192

7293
Ok(())
7394
}
@@ -82,7 +103,7 @@ async fn test_invalid_authorization_header_format() -> anyhow::Result<()> {
82103

83104
let response = token_client.delete::<()>(URL).await;
84105
assert_snapshot!(response.status(), @"401 Unauthorized");
85-
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authorization header"}]}"#);
106+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid `Authorization` header: Failed to parse token"}]}"#);
86107

87108
Ok(())
88109
}
@@ -97,7 +118,7 @@ async fn test_invalid_token_format() -> anyhow::Result<()> {
97118

98119
let response = token_client.delete::<()>(URL).await;
99120
assert_snapshot!(response.status(), @"401 Unauthorized");
100-
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authorization header"}]}"#);
121+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid `Authorization` header: Failed to parse token"}]}"#);
101122

102123
Ok(())
103124
}

0 commit comments

Comments
 (0)