security upgrade

This commit is contained in:
Mateusz Gruszczyński
2026-07-30 09:57:43 +02:00
parent 2274cf57c9
commit f6b8e016a8
24 changed files with 1100 additions and 272 deletions
+138 -39
View File
@@ -40,21 +40,28 @@ const MAX_PASSWORD_LENGTH: usize = 128;
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty())
crate::security::session_token(headers)
}
fn authorization_token(headers: &HeaderMap) -> Option<&str> {
crate::security::bearer_token(headers)
}
fn user_session_token(headers: &HeaderMap) -> Option<&str> {
headers
.get("x-rustpad-user-token")
.and_then(|value| value.to_str().ok())
crate::security::session_token(headers)
}
fn resource_request_token<'a>(
headers: &'a HeaderMap,
kind: &str,
slug: &str,
supplied: Option<&'a str>,
) -> Option<&'a str> {
supplied
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| bearer_token(headers))
.filter(|value| !value.is_empty() && *value != "cookie")
.or_else(|| crate::security::resource_token(headers, kind, slug))
.or_else(|| authorization_token(headers))
}
async fn session_user(
@@ -75,14 +82,19 @@ async fn has_write_permission(
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
let bearer = bearer_token(headers);
if token_access_level(state, kind, slug, bearer).await? >= AccessLevel::Write {
let resource = crate::security::resource_token(headers, kind, slug);
if external_token_access_level(state, kind, slug, resource).await? >= AccessLevel::Write {
return Ok(true);
}
let session = user_session_token(headers);
if session.is_some() && session != bearer {
return Ok(token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
let authorization = authorization_token(headers);
if authorization != resource
&& external_token_access_level(state, kind, slug, authorization).await? >= AccessLevel::Write
{
return Ok(true);
}
let session = crate::security::session_cookie_token(headers);
if session != resource && session != authorization {
return Ok(account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
}
Ok(false)
}
@@ -476,10 +488,10 @@ pub async fn workspace_info(
.ok_or_else(ApiError::not_found_workspace)?;
ensure_private_resource_access(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
bearer_token(&headers),
)
.await?;
Ok(Json(workspace_info_from(&workspace)))
@@ -495,8 +507,9 @@ pub async fn open_workspace(
&state,
&workspace_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let stats = db::list_note_stats(&state.db, workspace.id)
@@ -541,8 +554,9 @@ pub async fn create_note(
&state,
&workspace_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
@@ -554,7 +568,7 @@ pub async fn create_note(
&state,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
)
.await?
@@ -692,10 +706,10 @@ pub async fn note_info(
.ok_or_else(ApiError::not_found_workspace)?;
ensure_private_resource_access(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
bearer_token(&headers),
)
.await?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
@@ -807,8 +821,9 @@ pub async fn history(
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let _ = workspace;
@@ -834,8 +849,9 @@ pub async fn restore(
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
@@ -847,7 +863,7 @@ pub async fn restore(
&state,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
)
.await?
@@ -919,13 +935,13 @@ async fn anonymous_access_token_valid(
Ok(count > 0)
}
async fn token_access_level(
async fn external_token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let permission = crate::auth::resource_permission(state, kind, slug, token)
let permission = crate::auth::share_link_permission(state, kind, slug, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
let level = permission_level(permission.as_deref());
@@ -940,16 +956,28 @@ async fn token_access_level(
Ok(AccessLevel::None)
}
async fn account_token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let permission = crate::auth::account_resource_permission(state, kind, slug, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
Ok(permission_level(permission.as_deref()))
}
async fn combined_token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
access_token: Option<&str>,
bearer: Option<&str>,
account_token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
Ok(std::cmp::max(
token_access_level(state, kind, slug, access_token).await?,
token_access_level(state, kind, slug, bearer).await?,
external_token_access_level(state, kind, slug, access_token).await?,
account_token_access_level(state, kind, slug, account_token).await?,
))
}
@@ -961,28 +989,89 @@ fn require_write(level: AccessLevel) -> Result<(), ApiError> {
}
}
async fn has_header_resource_access(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
for token in [
crate::security::resource_token(headers, kind, slug),
authorization_token(headers),
] {
if external_token_access_level(state, kind, slug, token).await? != AccessLevel::None {
return Ok(true);
}
}
Ok(account_token_access_level(
state,
kind,
slug,
crate::security::session_cookie_token(headers),
)
.await?
!= AccessLevel::None)
}
async fn ensure_private_resource_access(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
is_private: i64,
token: Option<&str>,
) -> Result<(), ApiError> {
if is_private == 0 {
return Ok(());
}
if verify_resource_access_token(state, kind, slug, token).await? {
if has_header_resource_access(state, headers, kind, slug).await? {
return Ok(());
}
Err(ApiError::not_found_workspace())
}
async fn check_resource_password_attempt(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
password: Option<&str>,
password_ok: bool,
) -> Result<(), ApiError> {
let Some(_) = password.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(());
};
let client_key = crate::security::client_key(headers);
let window = std::time::Duration::from_secs(15 * 60);
state
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
.await
.map_err(|seconds| {
ApiError::rate_limited(&format!(
"Too many password attempts. Try again in {seconds} seconds."
))
})?;
let limit_key = format!("resource-password:{client_key}:{kind}:{slug}");
state
.check_rate_limit(limit_key.clone(), 10, window)
.await
.map_err(|seconds| {
ApiError::rate_limited(&format!(
"Too many password attempts. Try again in {seconds} seconds."
))
})?;
if password_ok {
state.clear_rate_limit(&limit_key).await;
}
Ok(())
}
pub async fn authorized_workspace(
state: &SharedState,
slug: &str,
password: Option<&str>,
access_token: Option<&str>,
bearer: Option<&str>,
headers: &HeaderMap,
) -> Result<db::Workspace, ApiError> {
let workspace = db::find_workspace(&state.db, slug)
.await?
@@ -992,11 +1081,13 @@ pub async fn authorized_workspace(
if workspace.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::not_found_workspace());
}
if workspace.password_hash.is_some()
&& !db::verify_workspace_password(&workspace, password)
&& token_level == AccessLevel::None
{
return Err(ApiError::unauthorized());
if workspace.password_hash.is_some() && token_level < AccessLevel::Write {
let password_ok = db::verify_workspace_password(&workspace, password);
check_resource_password_attempt(state, headers, "workspace", slug, password, password_ok)
.await?;
if token_level == AccessLevel::None && !password_ok {
return Err(ApiError::unauthorized());
}
}
Ok(workspace)
}
@@ -1008,9 +1099,17 @@ async fn authorized_note(
password: Option<&str>,
access_token: Option<&str>,
bearer: Option<&str>,
headers: &HeaderMap,
) -> Result<(db::Workspace, db::Note), ApiError> {
let workspace =
authorized_workspace(state, workspace_slug, password, access_token, bearer).await?;
let workspace = authorized_workspace(
state,
workspace_slug,
password,
access_token,
bearer,
headers,
)
.await?;
let note = db::find_note(&state.db, workspace.id, note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;