mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-07-02 04:25:33 +00:00
refactor(types,register): introduce IdUrl
and related types into submod
- `IdUrl` does basic validation for identity URL. Server could enforce additional restrictions on their own need. - single-label doamins are now rejected by default. - More tests are added for `IdUrl` validation.
This commit is contained in:
parent
25936cc4f7
commit
fac380fe55
7 changed files with 262 additions and 81 deletions
|
@ -3,9 +3,9 @@ use std::time::{Duration, Instant};
|
|||
|
||||
use anyhow::{anyhow, ensure, Context};
|
||||
use axum::http::{HeaderMap, HeaderName, StatusCode};
|
||||
use blah_types::identity::{IdUrl, UserIdentityDesc};
|
||||
use blah_types::{
|
||||
get_timestamp, PubKey, Signed, UserIdentityDesc, UserRegisterPayload, X_BLAH_DIFFICULTY,
|
||||
X_BLAH_NONCE,
|
||||
get_timestamp, PubKey, Signed, UserRegisterPayload, X_BLAH_DIFFICULTY, X_BLAH_NONCE,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use parking_lot::Mutex;
|
||||
|
@ -14,17 +14,11 @@ use rand::RngCore;
|
|||
use rusqlite::{named_params, params, OptionalExtension};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::{Host, Url};
|
||||
|
||||
use crate::{ApiError, AppState};
|
||||
|
||||
const USER_AGENT: &str = concat!("blahd/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Max domain length is limited by TLS certificate CommonName `ub-common-name`,
|
||||
/// which is 64. Adding the schema and port, it should still be below 80.
|
||||
/// Ref: https://www.rfc-editor.org/rfc/rfc3280
|
||||
const MAX_ID_URL_LEN: usize = 80;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
|
@ -38,6 +32,7 @@ pub struct Config {
|
|||
|
||||
pub unsafe_allow_id_url_http: bool,
|
||||
pub unsafe_allow_id_url_custom_port: bool,
|
||||
pub unsafe_allow_id_url_single_label: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
@ -53,10 +48,32 @@ impl Default for Config {
|
|||
|
||||
unsafe_allow_id_url_http: false,
|
||||
unsafe_allow_id_url_custom_port: false,
|
||||
unsafe_allow_id_url_single_label: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Check if the Identity URL is valid under the config.
|
||||
/// This only does additional checking besides rules of [`IdUrl`].
|
||||
fn validate_id_url(&self, url: &IdUrl) -> Result<(), &'static str> {
|
||||
if !self.unsafe_allow_id_url_http && url.scheme() == "http" {
|
||||
return Err("http id_url is not permitted for this server");
|
||||
}
|
||||
if !self.unsafe_allow_id_url_custom_port && url.port().is_some() {
|
||||
return Err("id_url with custom port is not permitted for this server");
|
||||
}
|
||||
let host = url.host_str().expect("checked by IdUrl");
|
||||
if host.starts_with('.') || host.ends_with('.') {
|
||||
return Err("unpermitted id_url with starting or trailing dot");
|
||||
}
|
||||
if !self.unsafe_allow_id_url_single_label && !host.contains('.') {
|
||||
return Err("single-label id_url is not permitted for this server");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
nonces: Mutex<Nonces>,
|
||||
|
@ -131,31 +148,6 @@ impl State {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check if the Identity URL is valid under the config.
|
||||
///
|
||||
/// We only accept simple HTTPS (and HTTP, if configured) domains. It must not be an IP host and
|
||||
/// must not have other parts like username, query, and etc.
|
||||
///
|
||||
/// Ref: https://docs.rs/url/2.5.2/url/enum.Position.html
|
||||
/// ```text
|
||||
/// url =
|
||||
/// scheme ":"
|
||||
/// [ "//" [ username [ ":" password ]? "@" ]? host [ ":" port ]? ]?
|
||||
/// path [ "?" query ]? [ "#" fragment ]?
|
||||
/// ```
|
||||
fn is_id_url_valid(config: &Config, url: &Url) -> bool {
|
||||
use url::Position;
|
||||
|
||||
url.as_str().len() <= MAX_ID_URL_LEN
|
||||
&& (url.scheme() == "https" || config.unsafe_allow_id_url_http && url.scheme() == "http")
|
||||
&& &url[Position::AfterScheme..Position::BeforeHost] == "://"
|
||||
&& url
|
||||
.host()
|
||||
.is_some_and(|host| matches!(host, Host::Domain(_)))
|
||||
&& (config.unsafe_allow_id_url_custom_port || url.port().is_none())
|
||||
&& &url[Position::BeforePath..] == "/"
|
||||
}
|
||||
|
||||
pub async fn user_register(
|
||||
st: &AppState,
|
||||
msg: Signed<UserRegisterPayload>,
|
||||
|
@ -178,11 +170,11 @@ pub async fn user_register(
|
|||
"unexpected server url in payload",
|
||||
));
|
||||
}
|
||||
if !is_id_url_valid(&st.config.register, ®.id_url) {
|
||||
if let Err(err) = st.config.register.validate_id_url(®.id_url) {
|
||||
return Err(error_response!(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_id_url",
|
||||
"invalid identity URL",
|
||||
"{err}",
|
||||
));
|
||||
}
|
||||
if !st.register.nonce().contains(®.challenge_nonce) {
|
||||
|
@ -247,7 +239,7 @@ pub async fn user_register(
|
|||
return Err(error_response!(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"fetch_id_description",
|
||||
"failed to fetch identity description from domain {}: {}",
|
||||
"failed to fetch identity description from {}: {}",
|
||||
reg.id_url,
|
||||
err,
|
||||
))
|
||||
|
@ -323,7 +315,7 @@ pub async fn user_register(
|
|||
}
|
||||
|
||||
fn validate_id_desc(
|
||||
id_url: &Url,
|
||||
id_url: &IdUrl,
|
||||
id_key: &PubKey,
|
||||
id_desc: &UserIdentityDesc,
|
||||
now: u64,
|
||||
|
@ -368,3 +360,30 @@ fn validate_id_desc(
|
|||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reject_unpermitted_id_url() {
|
||||
let mut conf = Config::default();
|
||||
let http_url = "http://example.com".parse().unwrap();
|
||||
conf.validate_id_url(&http_url).unwrap_err();
|
||||
conf.unsafe_allow_id_url_http = true;
|
||||
conf.validate_id_url(&http_url).unwrap();
|
||||
|
||||
let custom_port = "https://example.com:8080".parse().unwrap();
|
||||
conf.validate_id_url(&custom_port).unwrap_err();
|
||||
conf.unsafe_allow_id_url_custom_port = true;
|
||||
conf.validate_id_url(&custom_port).unwrap();
|
||||
|
||||
let single_label = "https://localhost".parse().unwrap();
|
||||
conf.validate_id_url(&single_label).unwrap_err();
|
||||
conf.unsafe_allow_id_url_single_label = true;
|
||||
conf.validate_id_url(&single_label).unwrap();
|
||||
|
||||
conf.validate_id_url(&"https://.".parse().unwrap())
|
||||
.unwrap_err();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,11 +9,12 @@ use std::time::{Duration, Instant};
|
|||
|
||||
use anyhow::Result;
|
||||
use axum::http::HeaderMap;
|
||||
use blah_types::identity::{IdUrl, UserActKeyDesc, UserIdentityDesc, UserProfile};
|
||||
use blah_types::{
|
||||
get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id,
|
||||
MemberPermission, PubKey, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata,
|
||||
ServerPermission, Signed, SignedChatMsg, UserActKeyDesc, UserIdentityDesc, UserKey,
|
||||
UserProfile, UserRegisterPayload, WithMsgId, X_BLAH_DIFFICULTY, X_BLAH_NONCE,
|
||||
ServerPermission, Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId,
|
||||
X_BLAH_DIFFICULTY, X_BLAH_NONCE,
|
||||
};
|
||||
use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs};
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
@ -29,7 +30,6 @@ use serde::de::DeserializeOwned;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::TcpListener;
|
||||
use url::Url;
|
||||
|
||||
// Register API requires a non-IP hostname.
|
||||
const LOCALHOST: &str = "localhost";
|
||||
|
@ -48,6 +48,7 @@ difficulty = {REGISTER_DIFFICULTY}
|
|||
request_timeout_secs = 1
|
||||
unsafe_allow_id_url_http = true
|
||||
unsafe_allow_id_url_custom_port = true
|
||||
unsafe_allow_id_url_single_label = true
|
||||
"#
|
||||
)
|
||||
};
|
||||
|
@ -856,9 +857,9 @@ async fn register(server: Server) {
|
|||
|
||||
let mut req = UserRegisterPayload {
|
||||
id_key: CAROL.pubkeys.id_key.clone(),
|
||||
// Fake values.
|
||||
server_url: "http://invalid.example.com".parse().unwrap(),
|
||||
id_url: "file:///etc/passwd".parse().unwrap(),
|
||||
// Invalid values.
|
||||
server_url: "http://localhost".parse().unwrap(),
|
||||
id_url: "http://.".parse().unwrap(),
|
||||
challenge_nonce: challenge_nonce - 1,
|
||||
};
|
||||
let register = |req: Signed<UserRegisterPayload>| {
|
||||
|
@ -897,7 +898,9 @@ async fn register(server: Server) {
|
|||
|
||||
let listener = TcpListener::bind(format!("{LOCALHOST}:0")).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
req.id_url = Url::parse(&format!("http://{LOCALHOST}:{port}")).unwrap();
|
||||
req.id_url = format!("http://{LOCALHOST}:{port}")
|
||||
.parse::<IdUrl>()
|
||||
.unwrap();
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route(
|
||||
|
@ -962,7 +965,7 @@ async fn register(server: Server) {
|
|||
(StatusCode::OK, desc.clone())
|
||||
}}
|
||||
};
|
||||
let sign_profile = |url: Url| {
|
||||
let sign_profile = |url: IdUrl| {
|
||||
server.sign(
|
||||
&CAROL,
|
||||
UserProfile {
|
||||
|
@ -985,7 +988,7 @@ async fn register(server: Server) {
|
|||
},
|
||||
)
|
||||
.unwrap();
|
||||
let profile = sign_profile(req.id_url.join("/mismatch").unwrap());
|
||||
let profile = sign_profile("https://localhost".parse().unwrap());
|
||||
UserIdentityDesc {
|
||||
id_key: CAROL.pubkeys.id_key.clone(),
|
||||
act_keys: vec![act_key],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue