mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-12 15:11:08 +00:00
1449 lines
45 KiB
Rust
1449 lines
45 KiB
Rust
#![allow(unused_imports)]
|
|
#![allow(unused_variables)]
|
|
|
|
mod common;
|
|
mod helpers;
|
|
|
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
use bspds::oauth::dpop::{DPoPVerifier, DPoPJwk, compute_jwk_thumbprint};
|
|
use chrono::Utc;
|
|
use common::{base_url, client};
|
|
use reqwest::{redirect, StatusCode};
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
use wiremock::matchers::{method, path};
|
|
|
|
fn no_redirect_client() -> reqwest::Client {
|
|
reqwest::Client::builder()
|
|
.redirect(redirect::Policy::none())
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
fn generate_pkce() -> (String, String) {
|
|
let verifier_bytes: [u8; 32] = rand::random();
|
|
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
|
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(code_verifier.as_bytes());
|
|
let hash = hasher.finalize();
|
|
let code_challenge = URL_SAFE_NO_PAD.encode(&hash);
|
|
|
|
(code_verifier, code_challenge)
|
|
}
|
|
|
|
async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
|
|
let mock_server = MockServer::start().await;
|
|
|
|
let client_id = mock_server.uri();
|
|
let metadata = json!({
|
|
"client_id": client_id,
|
|
"client_name": "Security Test Client",
|
|
"redirect_uris": [redirect_uri],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none",
|
|
"dpop_bound_access_tokens": false
|
|
});
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
|
|
.mount(&mock_server)
|
|
.await;
|
|
|
|
mock_server
|
|
}
|
|
|
|
async fn get_oauth_tokens(
|
|
http_client: &reqwest::Client,
|
|
url: &str,
|
|
) -> (String, String, String) {
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("sec-test-{}", ts);
|
|
let email = format!("sec-test-{}@example.com", ts);
|
|
let password = "security-test-password";
|
|
|
|
http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let redirect_uri = "https://example.com/sec-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let (code_verifier, code_challenge) = generate_pkce();
|
|
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
|
|
let auth_client = no_redirect_client();
|
|
let auth_res = auth_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.form(&[
|
|
("request_uri", request_uri),
|
|
("username", &handle),
|
|
("password", password),
|
|
("remember_device", "false"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
|
|
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
|
|
|
|
let token_body: Value = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let access_token = token_body["access_token"].as_str().unwrap().to_string();
|
|
let refresh_token = token_body["refresh_token"].as_str().unwrap().to_string();
|
|
|
|
(access_token, refresh_token, client_id)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_forged_token_signature_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
|
|
|
|
let parts: Vec<&str> = access_token.split('.').collect();
|
|
assert_eq!(parts.len(), 3, "Token should have 3 parts");
|
|
|
|
let forged_signature = URL_SAFE_NO_PAD.encode(&[0u8; 32]);
|
|
let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_signature);
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", format!("Bearer {}", forged_token))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Forged signature should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_modified_payload_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
|
|
|
|
let parts: Vec<&str> = access_token.split('.').collect();
|
|
|
|
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
|
|
let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
|
|
payload["sub"] = json!("did:plc:attacker");
|
|
let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", format!("Bearer {}", modified_token))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_algorithm_none_attack_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let header = json!({
|
|
"alg": "none",
|
|
"typ": "at+jwt"
|
|
});
|
|
let payload = json!({
|
|
"iss": "https://test.pds",
|
|
"sub": "did:plc:attacker",
|
|
"aud": "https://test.pds",
|
|
"iat": Utc::now().timestamp(),
|
|
"exp": Utc::now().timestamp() + 3600,
|
|
"jti": "fake-token-id",
|
|
"scope": "atproto"
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let malicious_token = format!("{}.{}.", header_b64, payload_b64);
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", format!("Bearer {}", malicious_token))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm 'none' attack should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_algorithm_substitution_attack_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let header = json!({
|
|
"alg": "RS256",
|
|
"typ": "at+jwt"
|
|
});
|
|
let payload = json!({
|
|
"iss": "https://test.pds",
|
|
"sub": "did:plc:attacker",
|
|
"aud": "https://test.pds",
|
|
"iat": Utc::now().timestamp(),
|
|
"exp": Utc::now().timestamp() + 3600,
|
|
"jti": "fake-token-id"
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]);
|
|
let malicious_token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", format!("Bearer {}", malicious_token))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm substitution attack should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_expired_token_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let header = json!({
|
|
"alg": "HS256",
|
|
"typ": "at+jwt"
|
|
});
|
|
let payload = json!({
|
|
"iss": "https://test.pds",
|
|
"sub": "did:plc:test",
|
|
"aud": "https://test.pds",
|
|
"iat": Utc::now().timestamp() - 7200,
|
|
"exp": Utc::now().timestamp() - 3600,
|
|
"jti": "expired-token-id"
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]);
|
|
let expired_token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", format!("Bearer {}", expired_token))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_pkce_plain_method_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let redirect_uri = "https://example.com/pkce-plain-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let res = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", "plain-text-challenge"),
|
|
("code_challenge_method", "plain"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "PKCE plain method should be rejected");
|
|
let body: Value = res.json().await.unwrap();
|
|
assert_eq!(body["error"], "invalid_request");
|
|
assert!(
|
|
body["error_description"].as_str().unwrap().to_lowercase().contains("s256"),
|
|
"Error should mention S256 requirement"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_pkce_missing_challenge_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let redirect_uri = "https://example.com/no-pkce-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let res = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_pkce_wrong_verifier_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("pkce-attack-{}", ts);
|
|
let email = format!("pkce-attack-{}@example.com", ts);
|
|
let password = "pkce-attack-password";
|
|
|
|
http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let redirect_uri = "https://example.com/pkce-attack-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let (_, code_challenge) = generate_pkce();
|
|
let (attacker_verifier, _) = generate_pkce();
|
|
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
|
|
let auth_client = no_redirect_client();
|
|
let auth_res = auth_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.form(&[
|
|
("request_uri", request_uri),
|
|
("username", &handle),
|
|
("password", password),
|
|
("remember_device", "false"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
|
|
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
|
|
|
|
let token_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &attacker_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(token_res.status(), StatusCode::BAD_REQUEST, "Wrong PKCE verifier should be rejected");
|
|
let body: Value = token_res.json().await.unwrap();
|
|
assert_eq!(body["error"], "invalid_grant");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_authorization_code_replay_attack() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("code-replay-{}", ts);
|
|
let email = format!("code-replay-{}@example.com", ts);
|
|
let password = "code-replay-password";
|
|
|
|
http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let redirect_uri = "https://example.com/code-replay-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let (code_verifier, code_challenge) = generate_pkce();
|
|
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
|
|
let auth_client = no_redirect_client();
|
|
let auth_res = auth_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.form(&[
|
|
("request_uri", request_uri),
|
|
("username", &handle),
|
|
("password", password),
|
|
("remember_device", "false"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
|
|
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
|
|
let stolen_code = code.to_string();
|
|
|
|
let first_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(first_res.status(), StatusCode::OK, "First use should succeed");
|
|
|
|
let replay_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", &stolen_code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Replay attack should fail");
|
|
let body: Value = replay_res.json().await.unwrap();
|
|
assert_eq!(body["error"], "invalid_grant");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_refresh_token_replay_attack() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("rt-replay-{}", ts);
|
|
let email = format!("rt-replay-{}@example.com", ts);
|
|
let password = "rt-replay-password";
|
|
|
|
http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let redirect_uri = "https://example.com/rt-replay-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let (code_verifier, code_challenge) = generate_pkce();
|
|
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
|
|
let auth_client = no_redirect_client();
|
|
let auth_res = auth_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.form(&[
|
|
("request_uri", request_uri),
|
|
("username", &handle),
|
|
("password", password),
|
|
("remember_device", "false"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
|
|
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
|
|
|
|
let token_body: Value = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let stolen_refresh_token = token_body["refresh_token"].as_str().unwrap().to_string();
|
|
|
|
let first_refresh: Value = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", &stolen_refresh_token),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(first_refresh["access_token"].is_string(), "First refresh should succeed");
|
|
let new_refresh_token = first_refresh["refresh_token"].as_str().unwrap();
|
|
|
|
let replay_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", &stolen_refresh_token),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail");
|
|
let body: Value = replay_res.json().await.unwrap();
|
|
assert_eq!(body["error"], "invalid_grant");
|
|
assert!(
|
|
body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"),
|
|
"Error should mention token reuse"
|
|
);
|
|
|
|
let family_revoked_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", new_refresh_token),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
family_revoked_res.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Token family should be revoked after replay detection"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_redirect_uri_manipulation() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let registered_redirect = "https://legitimate-app.com/callback";
|
|
let attacker_redirect = "https://attacker.com/steal";
|
|
let mock_client = setup_mock_client_metadata(registered_redirect).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let (_, code_challenge) = generate_pkce();
|
|
|
|
let res = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", attacker_redirect),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_deactivated_account_blocked() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("deact-sec-{}", ts);
|
|
let email = format!("deact-sec-{}@example.com", ts);
|
|
let password = "deact-sec-password";
|
|
|
|
let create_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(create_res.status(), StatusCode::OK);
|
|
let account: Value = create_res.json().await.unwrap();
|
|
let access_jwt = account["accessJwt"].as_str().unwrap();
|
|
|
|
let deact_res = http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
|
|
.header("Authorization", format!("Bearer {}", access_jwt))
|
|
.json(&json!({}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(deact_res.status(), StatusCode::OK);
|
|
|
|
let redirect_uri = "https://example.com/deact-sec-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let (_, code_challenge) = generate_pkce();
|
|
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
|
|
let auth_res = http_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.form(&[
|
|
("request_uri", request_uri),
|
|
("username", &handle),
|
|
("password", password),
|
|
("remember_device", "false"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked from OAuth");
|
|
let body: Value = auth_res.json().await.unwrap();
|
|
assert_eq!(body["error"], "access_denied");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_url_injection_in_state_parameter() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("inject-state-{}", ts);
|
|
let email = format!("inject-state-{}@example.com", ts);
|
|
let password = "inject-state-password";
|
|
|
|
http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let redirect_uri = "https://example.com/inject-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
|
|
let (code_verifier, code_challenge) = generate_pkce();
|
|
|
|
let malicious_state = "state&redirect_uri=https://attacker.com&extra=";
|
|
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
("state", malicious_state),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
|
|
let auth_client = no_redirect_client();
|
|
let auth_res = auth_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.form(&[
|
|
("request_uri", request_uri),
|
|
("username", &handle),
|
|
("password", password),
|
|
("remember_device", "false"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(auth_res.status().is_redirection(), "Should redirect successfully");
|
|
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
|
|
|
|
assert!(
|
|
location.starts_with(redirect_uri),
|
|
"Redirect should go to registered URI, not attacker URI. Got: {}",
|
|
location
|
|
);
|
|
|
|
let redirect_uri_count = location.matches("redirect_uri=").count();
|
|
assert!(
|
|
redirect_uri_count <= 1,
|
|
"State injection should not add extra redirect_uri parameters"
|
|
);
|
|
|
|
assert!(
|
|
location.contains(&urlencoding::encode(malicious_state).to_string()) ||
|
|
location.contains("state=state%26redirect_uri"),
|
|
"State parameter should be properly URL-encoded. Got: {}",
|
|
location
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_cross_client_token_theft() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("cross-client-{}", ts);
|
|
let email = format!("cross-client-{}@example.com", ts);
|
|
let password = "cross-client-password";
|
|
|
|
http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({
|
|
"handle": handle,
|
|
"email": email,
|
|
"password": password
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let redirect_uri_a = "https://app-a.com/callback";
|
|
let mock_client_a = setup_mock_client_metadata(redirect_uri_a).await;
|
|
let client_id_a = mock_client_a.uri();
|
|
|
|
let redirect_uri_b = "https://app-b.com/callback";
|
|
let mock_client_b = setup_mock_client_metadata(redirect_uri_b).await;
|
|
let client_id_b = mock_client_b.uri();
|
|
|
|
let (code_verifier, code_challenge) = generate_pkce();
|
|
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id_a),
|
|
("redirect_uri", redirect_uri_a),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
|
|
let auth_client = no_redirect_client();
|
|
let auth_res = auth_client
|
|
.post(format!("{}/oauth/authorize", url))
|
|
.form(&[
|
|
("request_uri", request_uri),
|
|
("username", &handle),
|
|
("password", password),
|
|
("remember_device", "false"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
|
|
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
|
|
|
|
let token_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri_a),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id_b),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
token_res.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Cross-client code exchange must be explicitly rejected (defense-in-depth)"
|
|
);
|
|
let body: Value = token_res.json().await.unwrap();
|
|
assert_eq!(body["error"], "invalid_grant");
|
|
assert!(
|
|
body["error_description"].as_str().unwrap().contains("client_id"),
|
|
"Error should mention client_id mismatch"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_dpop_nonce_tamper_detection() {
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
|
|
let nonce = verifier.generate_nonce();
|
|
let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap();
|
|
|
|
let mut tampered = nonce_bytes.clone();
|
|
if !tampered.is_empty() {
|
|
tampered[0] ^= 0xFF;
|
|
}
|
|
let tampered_nonce = URL_SAFE_NO_PAD.encode(&tampered);
|
|
|
|
let result = verifier.validate_nonce(&tampered_nonce);
|
|
assert!(result.is_err(), "Tampered nonce should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_dpop_nonce_cross_server_rejected() {
|
|
let secret1 = b"server-1-secret-32-bytes-long!!!";
|
|
let secret2 = b"server-2-secret-32-bytes-long!!!";
|
|
|
|
let verifier1 = DPoPVerifier::new(secret1);
|
|
let verifier2 = DPoPVerifier::new(secret2);
|
|
|
|
let nonce_from_server1 = verifier1.generate_nonce();
|
|
|
|
let result = verifier2.validate_nonce(&nonce_from_server1);
|
|
assert!(result.is_err(), "Nonce from different server should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_dpop_proof_signature_tampering() {
|
|
use p256::ecdsa::{SigningKey, Signature, signature::Signer};
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
|
|
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
let verifying_key = signing_key.verifying_key();
|
|
let point = verifying_key.to_encoded_point(false);
|
|
|
|
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
|
|
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
|
|
|
|
let header = json!({
|
|
"typ": "dpop+jwt",
|
|
"alg": "ES256",
|
|
"jwk": {
|
|
"kty": "EC",
|
|
"crv": "P-256",
|
|
"x": x,
|
|
"y": y
|
|
}
|
|
});
|
|
|
|
let payload = json!({
|
|
"jti": format!("tamper-test-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
"htm": "POST",
|
|
"htu": "https://example.com/token",
|
|
"iat": Utc::now().timestamp()
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
|
|
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
|
let signature: Signature = signing_key.sign(signing_input.as_bytes());
|
|
let mut sig_bytes = signature.to_bytes().to_vec();
|
|
|
|
sig_bytes[0] ^= 0xFF;
|
|
let tampered_sig = URL_SAFE_NO_PAD.encode(&sig_bytes);
|
|
|
|
let tampered_proof = format!("{}.{}.{}", header_b64, payload_b64, tampered_sig);
|
|
|
|
let result = verifier.verify_proof(&tampered_proof, "POST", "https://example.com/token", None);
|
|
assert!(result.is_err(), "Tampered DPoP signature should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_dpop_proof_key_substitution() {
|
|
use p256::ecdsa::{SigningKey, Signature, signature::Signer};
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
|
|
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
|
|
let attacker_key = SigningKey::random(&mut rand::thread_rng());
|
|
let attacker_verifying = attacker_key.verifying_key();
|
|
let attacker_point = attacker_verifying.to_encoded_point(false);
|
|
|
|
let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap());
|
|
let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap());
|
|
|
|
let header = json!({
|
|
"typ": "dpop+jwt",
|
|
"alg": "ES256",
|
|
"jwk": {
|
|
"kty": "EC",
|
|
"crv": "P-256",
|
|
"x": x,
|
|
"y": y
|
|
}
|
|
});
|
|
|
|
let payload = json!({
|
|
"jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
"htm": "POST",
|
|
"htu": "https://example.com/token",
|
|
"iat": Utc::now().timestamp()
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
|
let signature: Signature = signing_key.sign(signing_input.as_bytes());
|
|
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
|
|
|
let mismatched_proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
|
|
|
|
let result = verifier.verify_proof(&mismatched_proof, "POST", "https://example.com/token", None);
|
|
assert!(result.is_err(), "DPoP proof with mismatched key should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_jwk_thumbprint_consistency() {
|
|
let jwk = DPoPJwk {
|
|
kty: "EC".to_string(),
|
|
crv: Some("P-256".to_string()),
|
|
x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
|
|
y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
|
|
};
|
|
|
|
let mut results = Vec::new();
|
|
for _ in 0..100 {
|
|
results.push(compute_jwk_thumbprint(&jwk).unwrap());
|
|
}
|
|
|
|
let first = &results[0];
|
|
for (i, result) in results.iter().enumerate() {
|
|
assert_eq!(first, result, "Thumbprint should be deterministic, but iteration {} differs", i);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_dpop_iat_clock_skew_limits() {
|
|
use p256::ecdsa::{SigningKey, Signature, signature::Signer};
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
|
|
let test_offsets = vec![
|
|
(-600, true),
|
|
(-301, true),
|
|
(-299, false),
|
|
(0, false),
|
|
(299, false),
|
|
(301, true),
|
|
(600, true),
|
|
];
|
|
|
|
for (offset_secs, should_fail) in test_offsets {
|
|
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
let verifying_key = signing_key.verifying_key();
|
|
let point = verifying_key.to_encoded_point(false);
|
|
|
|
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
|
|
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
|
|
|
|
let header = json!({
|
|
"typ": "dpop+jwt",
|
|
"alg": "ES256",
|
|
"jwk": {
|
|
"kty": "EC",
|
|
"crv": "P-256",
|
|
"x": x,
|
|
"y": y
|
|
}
|
|
});
|
|
|
|
let payload = json!({
|
|
"jti": format!("clock-{}-{}", offset_secs, Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
"htm": "POST",
|
|
"htu": "https://example.com/token",
|
|
"iat": Utc::now().timestamp() + offset_secs
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
|
let signature: Signature = signing_key.sign(signing_input.as_bytes());
|
|
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
|
|
|
let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
|
|
|
|
let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
|
|
|
|
if should_fail {
|
|
assert!(result.is_err(), "iat offset {} should be rejected", offset_secs);
|
|
} else {
|
|
assert!(result.is_ok(), "iat offset {} should be accepted", offset_secs);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_security_dpop_method_case_insensitivity() {
|
|
use p256::ecdsa::{SigningKey, Signature, signature::Signer};
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
|
|
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
let verifying_key = signing_key.verifying_key();
|
|
let point = verifying_key.to_encoded_point(false);
|
|
|
|
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
|
|
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
|
|
|
|
let header = json!({
|
|
"typ": "dpop+jwt",
|
|
"alg": "ES256",
|
|
"jwk": {
|
|
"kty": "EC",
|
|
"crv": "P-256",
|
|
"x": x,
|
|
"y": y
|
|
}
|
|
});
|
|
|
|
let payload = json!({
|
|
"jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
"htm": "post",
|
|
"htu": "https://example.com/token",
|
|
"iat": Utc::now().timestamp()
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
|
let signature: Signature = signing_key.sign(signing_input.as_bytes());
|
|
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
|
|
|
let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
|
|
|
|
let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
|
|
assert!(result.is_ok(), "HTTP method comparison should be case-insensitive");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_invalid_grant_type_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let grant_types = vec![
|
|
"client_credentials",
|
|
"password",
|
|
"implicit",
|
|
"urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
"urn:ietf:params:oauth:grant-type:device_code",
|
|
"",
|
|
"AUTHORIZATION_CODE",
|
|
"Authorization_Code",
|
|
];
|
|
|
|
for grant_type in grant_types {
|
|
let res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", grant_type),
|
|
("client_id", "https://example.com"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Grant type '{}' should be rejected",
|
|
grant_type
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_token_with_wrong_typ_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let wrong_types = vec![
|
|
"JWT",
|
|
"jwt",
|
|
"at+JWT",
|
|
"access_token",
|
|
"",
|
|
];
|
|
|
|
for typ in wrong_types {
|
|
let header = json!({
|
|
"alg": "HS256",
|
|
"typ": typ
|
|
});
|
|
let payload = json!({
|
|
"iss": "https://test.pds",
|
|
"sub": "did:plc:test",
|
|
"aud": "https://test.pds",
|
|
"iat": Utc::now().timestamp(),
|
|
"exp": Utc::now().timestamp() + 3600,
|
|
"jti": "wrong-typ-token"
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]);
|
|
let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", format!("Bearer {}", token))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Token with typ='{}' should be rejected",
|
|
typ
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_missing_required_claims_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let tokens_missing_claims = vec![
|
|
(json!({"iss": "x", "sub": "x", "aud": "x", "iat": 0}), "exp"),
|
|
(json!({"iss": "x", "sub": "x", "aud": "x", "exp": 9999999999i64}), "iat"),
|
|
(json!({"iss": "x", "aud": "x", "iat": 0, "exp": 9999999999i64}), "sub"),
|
|
];
|
|
|
|
for (payload, missing_claim) in tokens_missing_claims {
|
|
let header = json!({
|
|
"alg": "HS256",
|
|
"typ": "at+jwt"
|
|
});
|
|
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]);
|
|
let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", format!("Bearer {}", token))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Token missing '{}' claim should be rejected",
|
|
missing_claim
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_malformed_tokens_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let malformed_tokens = vec![
|
|
"",
|
|
"not-a-token",
|
|
"one.two",
|
|
"one.two.three.four",
|
|
"....",
|
|
"eyJhbGciOiJIUzI1NiJ9",
|
|
"eyJhbGciOiJIUzI1NiJ9.",
|
|
"eyJhbGciOiJIUzI1NiJ9..",
|
|
".eyJzdWIiOiJ0ZXN0In0.",
|
|
"!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig",
|
|
"eyJhbGciOiJIUzI1NiJ9.!!invalid!!.sig",
|
|
];
|
|
|
|
for token in malformed_tokens {
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", format!("Bearer {}", token))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Malformed token '{}' should be rejected",
|
|
if token.len() > 50 { &token[..50] } else { token }
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_authorization_header_formats() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
|
|
|
|
let valid_case_variants = vec![
|
|
format!("bearer {}", access_token),
|
|
format!("BEARER {}", access_token),
|
|
format!("Bearer {}", access_token),
|
|
];
|
|
|
|
for auth_header in valid_case_variants {
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", &auth_header)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::OK,
|
|
"Auth header '{}...' should be accepted (RFC 7235 case-insensitivity)",
|
|
if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header }
|
|
);
|
|
}
|
|
|
|
let invalid_formats = vec![
|
|
format!("Basic {}", access_token),
|
|
format!("Digest {}", access_token),
|
|
access_token.clone(),
|
|
format!("Bearer{}", access_token),
|
|
];
|
|
|
|
for auth_header in invalid_formats {
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", &auth_header)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Auth header '{}...' should be rejected",
|
|
if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header }
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_no_authorization_header() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Missing auth header should return 401");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_empty_authorization_header() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let res = http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", "")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Empty auth header should return 401");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_security_revoked_token_rejected() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
|
|
let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await;
|
|
|
|
let revoke_res = http_client
|
|
.post(format!("{}/oauth/revoke", url))
|
|
.form(&[("token", &refresh_token)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(revoke_res.status(), StatusCode::OK);
|
|
|
|
let introspect_res = http_client
|
|
.post(format!("{}/oauth/introspect", url))
|
|
.form(&[("token", &access_token)])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
|
|
let introspect_body: Value = introspect_res.json().await.unwrap();
|
|
assert_eq!(introspect_body["active"], false, "Revoked token should be inactive");
|
|
}
|