fix2 tokens

This commit is contained in:
Mateusz Gruszczyński
2026-08-03 01:35:05 +02:00
parent 49dad1a5f4
commit e3ee6319b9
25 changed files with 1420 additions and 223 deletions
+13 -1
View File
@@ -105,13 +105,25 @@ pub async fn verify_resource_access_token(
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(false);
};
if crate::auth::share_link_permission(state, kind, slug, Some(token))
if crate::auth::share_access_permission(state, kind, slug, Some(token))
.await
.map_err(|error| ApiError::forbidden(&error.message))?
.is_some()
{
return Ok(true);
}
verify_password_access_token(state, kind, slug, Some(token)).await
}
pub(crate) async fn verify_password_access_token(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<bool, ApiError> {
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(false);
};
let count: i64 = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
+6 -3
View File
@@ -66,8 +66,9 @@ pub async fn upload_pad_file(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
@@ -306,8 +307,9 @@ pub async fn upload_note_file(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
@@ -387,8 +389,9 @@ pub async fn delete_note(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
+118 -36
View File
@@ -119,23 +119,19 @@ async fn has_write_permission(
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
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 authorization = authorization_token(headers);
if authorization != resource
&& external_token_access_level(state, kind, slug, authorization).await?
>= AccessLevel::Write
if request_access_level(
state,
headers,
kind,
slug,
None,
crate::security::session_cookie_token(headers),
)
.await?
>= AccessLevel::Write
{
return Ok(true);
}
let session = crate::security::session_cookie_token(headers);
if session != resource && session != authorization {
if account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write {
return Ok(true);
}
}
match kind {
"workspace" => Ok(db::find_workspace(&state.db, slug)
.await?
@@ -275,6 +271,7 @@ pub struct WorkspaceInfo {
slug: String,
title: String,
protected: bool,
access_level: String,
created_at: String,
updated_at: String,
}
@@ -332,6 +329,7 @@ pub struct NoteInfo {
slug: String,
title: String,
protected: bool,
access_level: String,
note_protected: bool,
allow_public_task_updates: bool,
public_page_unprotected: bool,
@@ -570,7 +568,16 @@ pub async fn workspace_info(
workspace.is_private,
)
.await?;
Ok(Json(workspace_info_from(&workspace)))
let access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
Ok(Json(workspace_info_from(&workspace, access_level)))
}
pub async fn open_workspace(
@@ -636,8 +643,20 @@ pub async fn open_workspace(
let start = (page - 1) * per_page;
let notes = notes.into_iter().skip(start).take(per_page).collect();
let mut access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
access_level = AccessLevel::Write;
}
Ok(Json(WorkspaceOpenResponse {
workspace: workspace_info_from(&workspace),
workspace: workspace_info_from(&workspace, access_level),
notes,
pagination: ListPaginationMeta { page, per_page, total, total_pages },
}))
@@ -668,8 +687,9 @@ pub async fn create_note(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
@@ -845,6 +865,15 @@ pub async fn note_info(
workspace.is_private,
)
.await?;
let access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
@@ -888,6 +917,7 @@ pub async fn note_info(
slug: note.slug,
title: note.title,
protected: workspace.password_hash.is_some(),
access_level: access_level_name(access_level).into(),
note_protected: note.protected,
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?,
@@ -1001,8 +1031,9 @@ pub async fn restore(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
@@ -1105,7 +1136,7 @@ async fn external_token_access_level(
slug: &str,
token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let permission = crate::auth::share_link_permission(state, kind, slug, token)
let permission = crate::auth::share_access_permission(state, kind, slug, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
let level = permission_level(permission.as_deref());
@@ -1132,17 +1163,73 @@ async fn account_token_access_level(
Ok(permission_level(permission.as_deref()))
}
async fn combined_token_access_level(
async fn request_access_level(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
access_token: Option<&str>,
supplied_access_token: Option<&str>,
account_token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
Ok(std::cmp::max(
external_token_access_level(state, kind, slug, access_token).await?,
account_token_access_level(state, kind, slug, account_token).await?,
))
let mut level = account_token_access_level(state, kind, slug, account_token).await?;
if level == AccessLevel::Write {
return Ok(level);
}
let mut checked_tokens = Vec::with_capacity(4);
for token in [
supplied_access_token,
crate::security::share_session_token(headers, kind, slug),
crate::security::resource_token(headers, kind, slug),
authorization_token(headers),
] {
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
continue;
};
if checked_tokens.contains(&token) {
continue;
}
checked_tokens.push(token);
level = std::cmp::max(
level,
external_token_access_level(state, kind, slug, Some(token)).await?,
);
if level == AccessLevel::Write {
break;
}
}
Ok(level)
}
fn access_level_name(level: AccessLevel) -> &'static str {
match level {
AccessLevel::None => "none",
AccessLevel::Read => "read",
AccessLevel::Write => "write",
}
}
async fn effective_header_access_level(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
is_private: i64,
password_protected: bool,
) -> Result<AccessLevel, ApiError> {
let mut level = request_access_level(
state,
headers,
kind,
slug,
None,
bearer_token(headers),
)
.await?;
if is_private == 0 && !password_protected {
level = std::cmp::max(level, AccessLevel::Write);
}
Ok(level)
}
fn require_write(level: AccessLevel) -> Result<(), ApiError> {
@@ -1159,18 +1246,12 @@ async fn has_header_resource_access(
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(
Ok(request_access_level(
state,
headers,
kind,
slug,
None,
crate::security::session_cookie_token(headers),
)
.await?
@@ -1241,7 +1322,7 @@ pub async fn authorized_workspace(
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let token_level =
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
request_access_level(state, headers, "workspace", slug, access_token, bearer).await?;
if workspace.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::not_found_workspace());
}
@@ -1280,11 +1361,12 @@ async fn authorized_note(
Ok((workspace, note))
}
fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo {
fn workspace_info_from(workspace: &db::Workspace, access_level: AccessLevel) -> WorkspaceInfo {
WorkspaceInfo {
slug: workspace.slug.clone(),
title: workspace.title.clone(),
protected: workspace.password_hash.is_some(),
access_level: access_level_name(access_level).into(),
created_at: db::normalize_timestamp(&workspace.created_at),
updated_at: db::normalize_timestamp(&workspace.updated_at),
}
+18 -4
View File
@@ -29,6 +29,7 @@ pub struct PadInfo {
slug: String,
title: String,
protected: bool,
access_level: String,
allow_public_task_updates: bool,
public_page_unprotected: bool,
public_page_enabled: bool,
@@ -106,6 +107,15 @@ pub async fn pad_info(
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?;
let access_level = effective_header_access_level(
&state,
&headers,
"pad",
&pad.slug,
pad.is_private,
pad.password_hash.is_some(),
)
.await?;
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
let (editor_preferences, personal_editor_settings) =
user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id))
@@ -133,6 +143,7 @@ pub async fn pad_info(
slug: pad.slug,
title: pad.title,
protected: pad.password_hash.is_some(),
access_level: access_level_name(access_level).into(),
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?,
public_page_enabled: db::pad_public_page_enabled(&state.db, pad.id).await?,
@@ -256,8 +267,9 @@ pub async fn publish_pad_page(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
@@ -311,8 +323,9 @@ pub async fn publish_note_page(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
@@ -527,8 +540,9 @@ pub async fn pad_restore(
{
AccessLevel::Write
} else {
combined_token_access_level(
request_access_level(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
@@ -577,7 +591,7 @@ pub(super) async fn authorized_pad(
let pad = db::find_pad(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
let token_level = request_access_level(state, headers, "pad", slug, access_token, bearer).await?;
if pad.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::not_found_note());
}
+21 -2
View File
@@ -19,11 +19,30 @@ use axum::{
};
use pages::*;
use tower::{ServiceBuilder, service_fn};
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use tower_http::{
services::ServeDir,
set_header::SetResponseHeaderLayer,
trace::{MakeSpan, TraceLayer},
};
use tracing::Span;
use crate::{api, auth, state::SharedState, websocket};
use std::convert::Infallible;
#[derive(Clone, Copy)]
struct PathOnlyMakeSpan;
impl<B> MakeSpan<B> for PathOnlyMakeSpan {
fn make_span(&mut self, request: &axum::http::Request<B>) -> Span {
tracing::info_span!(
"http_request",
method = %request.method(),
path = %request.uri().path(),
version = ?request.version(),
)
}
}
pub fn router(
state: SharedState,
static_dir: &str,
@@ -208,7 +227,7 @@ pub fn router(
.layer(DefaultBodyLimit::max(
upload_max_size_bytes.saturating_add(1024 * 1024),
))
.layer(TraceLayer::new_for_http())
.layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
.layer(middleware::from_fn(require_csrf_token))
.layer(middleware::from_fn(apply_response_header_policy))
.with_state(state)
+222 -34
View File
@@ -8,12 +8,123 @@
*/
use axum::{
extract::{Path, State},
http::{HeaderValue, StatusCode, header},
extract::{OriginalUri, Path, RawQuery, State},
http::{HeaderMap, HeaderValue, StatusCode, Uri, header},
response::{Html, IntoResponse, Response},
};
use crate::{assets, db, state::SharedState};
use crate::{assets, auth, db, state::SharedState};
fn decode_query_component(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let high = (bytes[index + 1] as char).to_digit(16)? as u8;
let low = (bytes[index + 2] as char).to_digit(16)? as u8;
decoded.push((high << 4) | low);
index += 3;
}
b'%' => return None,
b'+' => {
decoded.push(b' ');
index += 1;
}
byte => {
decoded.push(byte);
index += 1;
}
}
}
String::from_utf8(decoded).ok()
}
fn share_token_from_query(query: Option<&str>) -> (bool, Option<String>) {
let Some(query) = query else {
return (false, None);
};
for field in query.split('&') {
let (name, value) = field.split_once('=').unwrap_or((field, ""));
if decode_query_component(name).as_deref() == Some("share") {
return (true, decode_query_component(value));
}
}
(false, None)
}
fn canonical_resource_url(uri: &Uri) -> String {
let remaining_query = uri.query().map(|query| {
query
.split('&')
.filter(|field| {
let name = field.split_once('=').map_or(*field, |(name, _)| name);
decode_query_component(name).as_deref() != Some("share")
})
.collect::<Vec<_>>()
.join("&")
});
match remaining_query.as_deref().filter(|query| !query.is_empty()) {
Some(query) => format!("{}?{query}", uri.path()),
None => uri.path().to_owned(),
}
}
async fn share_session_redirect(
state: &SharedState,
headers: &HeaderMap,
uri: &Uri,
kind: &str,
slug: &str,
share: Option<&str>,
) -> Response {
let client_key = crate::security::client_key(headers);
let cookie = match share.map(str::trim).filter(|value| !value.is_empty()) {
Some(share) => match auth::create_share_session(
state,
kind,
slug,
share,
crate::security::share_session_token(headers, kind, slug),
&client_key,
)
.await
{
Ok(Some(session)) => Some(crate::security::share_session_cookie(
kind,
slug,
&session.token,
session.max_age_seconds,
)),
Ok(None) => None,
Err(error) => {
tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session");
None
}
},
None => None,
};
let mut response = StatusCode::SEE_OTHER.into_response();
response.headers_mut().insert(
header::LOCATION,
HeaderValue::from_str(&canonical_resource_url(uri))
.expect("request URI is a valid redirect location"),
);
if let Some(cookie) = cookie {
response.headers_mut().insert(header::SET_COOKIE, cookie);
}
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store, max-age=0"),
);
response.headers_mut().insert(
"referrer-policy",
HeaderValue::from_static("no-referrer"),
);
response
}
fn render_editor_page(
state: &SharedState,
@@ -153,19 +264,39 @@ pub(super) async fn home(State(state): State<SharedState>) -> Response {
)
}
pub(super) async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response {
pub(super) async fn pad(
State(state): State<SharedState>,
headers: HeaderMap,
Path(slug): Path<String>,
RawQuery(query): RawQuery,
OriginalUri(uri): OriginalUri,
) -> Response {
match db::find_pad(&state.db, &slug).await {
Ok(Some(pad)) => render_editor_page(
&state,
"pad",
"pad",
&pad.title,
"RustPad",
"/",
"home-brand",
"note",
"<kbd>Alt+Enter</kbd><span>New line while editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>",
),
Ok(Some(pad)) => {
let (has_share, share) = share_token_from_query(query.as_deref());
if has_share {
return share_session_redirect(
&state,
&headers,
&uri,
"pad",
&slug,
share.as_deref(),
)
.await;
}
render_editor_page(
&state,
"pad",
"pad",
&pad.title,
"RustPad",
"/",
"home-brand",
"note",
"<kbd>Alt+Enter</kbd><span>New line while editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
@@ -214,10 +345,25 @@ pub(super) async fn public_page(
pub(super) async fn workspace(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
RawQuery(query): RawQuery,
OriginalUri(uri): OriginalUri,
) -> Response {
match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => {
let (has_share, share) = share_token_from_query(query.as_deref());
if has_share {
return share_session_redirect(
&state,
&headers,
&uri,
"workspace",
&workspace_slug,
share.as_deref(),
)
.await;
}
let html = include_str!("../../static/workspace.html").replace(
"__WORKSPACE_TITLE__",
&escape_html(if workspace.is_private != 0 {
@@ -254,7 +400,10 @@ pub(super) async fn workspace(
pub(super) async fn note(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
RawQuery(query): RawQuery,
OriginalUri(uri): OriginalUri,
) -> Response {
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => workspace,
@@ -276,25 +425,39 @@ pub(super) async fn note(
};
match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => render_editor_page(
&state,
"note",
"note",
if workspace.is_private != 0 {
"Note"
} else {
&note.title
},
if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
},
&format!("/w/{workspace_slug}"),
"",
"workspace",
"",
),
Ok(Some(note)) => {
let (has_share, share) = share_token_from_query(query.as_deref());
if has_share {
return share_session_redirect(
&state,
&headers,
&uri,
"workspace",
&workspace_slug,
share.as_deref(),
)
.await;
}
render_editor_page(
&state,
"note",
"note",
if workspace.is_private != 0 {
"Note"
} else {
&note.title
},
if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
},
&format!("/w/{workspace_slug}"),
"",
"workspace",
"",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
@@ -400,3 +563,28 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn share_exchange_redirects_to_canonical_resource_path() {
let uri: Uri = "/w/private?view=all&share=secret&page=2".parse().unwrap();
assert_eq!(canonical_resource_url(&uri), "/w/private?view=all&page=2");
}
#[test]
fn encoded_or_repeated_parameters_are_parsed_without_rejection() {
let uri: Uri = "/w/private?%73hare=one&share=two".parse().unwrap();
assert_eq!(share_token_from_query(uri.query()), (true, Some("one".into())));
assert_eq!(canonical_resource_url(&uri), "/w/private");
}
#[test]
fn malformed_share_parameter_is_still_removed_from_the_url() {
let uri: Uri = "/w/private?share=%ZZ&keep=no".parse().unwrap();
assert_eq!(share_token_from_query(uri.query()), (true, None));
assert_eq!(canonical_resource_url(&uri), "/w/private?keep=no");
}
}
+307 -23
View File
@@ -149,7 +149,6 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow {
#[derive(Debug)]
struct SharingLinkRow {
token_hash: String,
token: Option<String>,
permission: String,
expires_at: Option<String>,
created_at: String,
@@ -159,14 +158,68 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
token_hash: crate::row_decode::text(row, 0)?,
token: crate::row_decode::optional_text(row, 1)?,
permission: crate::row_decode::text(row, 2)?,
expires_at: crate::row_decode::optional_text(row, 3)?,
created_at: crate::row_decode::text(row, 4)?,
permission: crate::row_decode::text(row, 1)?,
expires_at: crate::row_decode::optional_text(row, 2)?,
created_at: crate::row_decode::text(row, 3)?,
})
}
}
#[derive(Debug)]
struct ShareLinkSessionSource {
token_hash: String,
permission: String,
expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkSessionSource {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
token_hash: crate::row_decode::text(row, 0)?,
permission: crate::row_decode::text(row, 1)?,
expires_at: crate::row_decode::optional_text(row, 2)?,
})
}
}
#[derive(Debug)]
struct ShareLinkPermissionRow {
permission: String,
expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkPermissionRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
permission: crate::row_decode::text(row, 0)?,
expires_at: crate::row_decode::optional_text(row, 1)?,
})
}
}
#[derive(Debug)]
struct ShareSessionPermissionRow {
permission: String,
session_expires_at: String,
link_expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareSessionPermissionRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
permission: crate::row_decode::text(row, 0)?,
session_expires_at: crate::row_decode::text(row, 1)?,
link_expires_at: crate::row_decode::optional_text(row, 2)?,
})
}
}
#[derive(Debug)]
pub struct ShareSession {
pub token: String,
pub max_age_seconds: i64,
}
#[derive(Debug)]
struct PendingShareRow {
email: String,
@@ -1681,7 +1734,7 @@ pub async fn resource_sharing(
.await
.map_err(AuthError::database)?;
Ok(Json(
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"token":row.token,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
))
}
@@ -1689,20 +1742,19 @@ pub async fn create_share_link(
State(state): State<SharedState>,
headers: HeaderMap,
Json(req): Json<CreateShareLinkRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
) -> Result<Response, AuthError> {
let owner = require_user(&state, &headers).await?;
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
let permission = validate_permission(&req.permission)?;
validate_share_expiration(req.expires_at.as_deref())?;
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
let token = random_token();
let token_hash = hash_token(&token);
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
.bind(token_hash)
.bind(&token)
.bind(&req.kind)
.bind(req.slug.trim())
.bind(permission)
.bind(&req.expires_at)
.bind(&expires_at)
.bind(owner.id)
.execute(state.db.pool())
.await
@@ -1712,9 +1764,19 @@ pub async fn create_share_link(
} else {
format!("/p/{}", req.slug.trim())
};
Ok(Json(
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}),
))
let mut response = Json(
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":expires_at}),
)
.into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
"no-store, max-age=0".parse().expect("valid cache-control"),
);
response.headers_mut().insert(
header::PRAGMA,
"no-cache".parse().expect("valid pragma"),
);
Ok(response)
}
pub async fn update_share_link(
@@ -1725,10 +1787,10 @@ pub async fn update_share_link(
let owner = require_user(&state, &headers).await?;
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
let permission = validate_permission(&req.permission)?;
validate_share_expiration(req.expires_at.as_deref())?;
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
.bind(permission)
.bind(&req.expires_at)
.bind(&expires_at)
.bind(req.token.trim())
.bind(&req.kind)
.bind(req.slug.trim())
@@ -1741,7 +1803,7 @@ pub async fn update_share_link(
));
}
Ok(Json(
serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}),
serde_json::json!({"ok":true,"permission":permission,"expires_at":expires_at}),
))
}
@@ -1760,6 +1822,16 @@ pub async fn revoke_share_link(
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_SESSIONS_DELETE_BY_LINK,
))
.bind(req.token.trim())
.bind(&req.kind)
.bind(req.slug.trim())
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"ok":true})))
}
@@ -1770,9 +1842,9 @@ fn validate_permission(value: &str) -> Result<&str, AuthError> {
}
}
fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
fn normalize_share_expiration(value: Option<&str>) -> Result<Option<String>, AuthError> {
let Some(value) = value else {
return Ok(());
return Ok(None);
};
let expires = chrono::DateTime::parse_from_rfc3339(value)
.map_err(|_| AuthError::bad_request("Invalid expiration date."))?
@@ -1780,7 +1852,7 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
if expires <= Utc::now() {
return Err(AuthError::bad_request("Expiration must be in the future."));
}
Ok(())
Ok(Some(expires.to_rfc3339()))
}
pub async fn is_resource_owner(
@@ -1826,6 +1898,194 @@ pub async fn account_resource_permission(
Ok(permission)
}
pub async fn create_share_session(
state: &SharedState,
kind: &str,
slug: &str,
share_token: &str,
existing_session_token: Option<&str>,
client_key: &str,
) -> Result<Option<ShareSession>, AuthError> {
let share_token = share_token.trim();
if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") {
return Ok(None);
}
let now = Utc::now();
let now_text = now.to_rfc3339();
let window = std::time::Duration::from_secs(15 * 60);
state
.check_rate_limit(format!("share-session-client:{client_key}"), 120, window)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link attempts. Try again in {seconds} seconds."
))
})?;
let source = sqlx::query_as::<_, ShareLinkSessionSource>(queries::get(
state.db.kind(),
queries::SHARE_LINK_SESSION_SOURCE,
))
.bind(hash_token(share_token))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
let Some(source) = source else {
return Ok(None);
};
if !matches!(source.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share link permission in database");
return Ok(None);
}
if source.permission == "ro"
&& share_session_permission(state, kind, slug, existing_session_token)
.await?
.as_deref()
== Some("rw")
{
return Ok(None);
}
let session_limit = now + Duration::days(state.anonymous_access_token_ttl_days);
let expires_at = match source.expires_at.as_deref() {
Some(value) => match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => std::cmp::min(value.with_timezone(&Utc), session_limit),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
},
None => session_limit,
};
let max_age_seconds = (expires_at - now).num_seconds();
if max_age_seconds <= 0 {
return Ok(None);
}
state
.check_rate_limit(
format!("share-session-client:{client_key}:{}", source.token_hash),
60,
window,
)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link sessions. Try again in {seconds} seconds."
))
})?;
state
.check_rate_limit(
format!("share-session-link:{}", source.token_hash),
2_000,
std::time::Duration::from_secs(60 * 60),
)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link sessions. Try again in {seconds} seconds."
))
})?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_SESSIONS_DELETE_EXPIRED,
))
.bind(&now_text)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
let token = random_token();
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_SESSION_INSERT,
))
.bind(hash_token(&token))
.bind(source.token_hash)
.bind(kind)
.bind(slug)
.bind(expires_at.to_rfc3339())
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(Some(ShareSession {
token,
max_age_seconds,
}))
}
async fn share_session_permission(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<Option<String>, AuthError> {
let Some(token) = token.filter(|value| !value.is_empty()) else {
return Ok(None);
};
if !valid_share_token(token) {
return Ok(None);
}
let row = sqlx::query_as::<_, ShareSessionPermissionRow>(queries::get(
state.db.kind(),
queries::SHARE_SESSION_PERMISSION,
))
.bind(hash_token(token))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
let Some(row) = row else {
return Ok(None);
};
if !matches!(row.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share session permission in database");
return Ok(None);
}
let now = Utc::now();
let session_expires = match chrono::DateTime::parse_from_rfc3339(&row.session_expires_at) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share session expiration in database");
return Ok(None);
}
};
if session_expires <= now {
return Ok(None);
}
if let Some(value) = row.link_expires_at.as_deref() {
let link_expires = match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
};
if link_expires <= now {
return Ok(None);
}
}
Ok(Some(row.permission))
}
pub async fn share_access_permission(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<Option<String>, AuthError> {
let permission = share_session_permission(state, kind, slug, token).await?;
if permission.is_some() {
return Ok(permission);
}
share_link_permission(state, kind, slug, token).await
}
pub async fn share_link_permission(
state: &SharedState,
kind: &str,
@@ -1835,19 +2095,39 @@ pub async fn share_link_permission(
let Some(token) = token.filter(|value| !value.is_empty()) else {
return Ok(None);
};
let now = Utc::now().to_rfc3339();
let permission: Option<String> = sqlx::query_scalar(queries::get(
if !valid_share_token(token) {
return Ok(None);
}
let row = sqlx::query_as::<_, ShareLinkPermissionRow>(queries::get(
state.db.kind(),
queries::SHARE_LINK_PERMISSION,
))
.bind(hash_token(token))
.bind(kind)
.bind(slug)
.bind(now)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(permission)
let Some(row) = row else {
return Ok(None);
};
if !matches!(row.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share link permission in database");
return Ok(None);
}
if let Some(value) = row.expires_at.as_deref() {
let expires = match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
};
if expires <= Utc::now() {
return Ok(None);
}
}
Ok(Some(row.permission))
}
pub async fn logout(
@@ -2286,6 +2566,10 @@ fn random_token() -> String {
random_hex_token::<32>()
}
fn valid_share_token(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn random_confirmation_token() -> String {
random_hex_token::<32>()
}
+18
View File
@@ -142,6 +142,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing::error!(%error, "failed to remove expired unconfirmed accounts")
}
}
let now = chrono::Utc::now().to_rfc3339();
match sqlx::query(crate::queries::get(
cleanup_state.db.kind(),
crate::queries::SHARE_SESSIONS_DELETE_EXPIRED,
))
.bind(now)
.execute(cleanup_state.db.pool())
.await
{
Ok(result) if result.rows_affected() > 0 => info!(
deleted = result.rows_affected(),
"removed expired share-link sessions"
),
Ok(_) => {}
Err(error) => {
tracing::error!(%error, "failed to remove expired share-link sessions")
}
}
}
});
let app = app::router(
+15
View File
@@ -105,6 +105,11 @@ pub enum Query {
SHARE_LINK_REVOKE,
RESOURCE_PERMISSION_BY_USER,
SHARE_LINK_PERMISSION,
SHARE_LINK_SESSION_SOURCE,
SHARE_SESSION_INSERT,
SHARE_SESSION_PERMISSION,
SHARE_SESSIONS_DELETE_BY_LINK,
SHARE_SESSIONS_DELETE_EXPIRED,
Q001,
Q002,
Q003,
@@ -259,6 +264,11 @@ pub const SHARE_LINK_UPDATE: Query = Query::SHARE_LINK_UPDATE;
pub const SHARE_LINK_REVOKE: Query = Query::SHARE_LINK_REVOKE;
pub const RESOURCE_PERMISSION_BY_USER: Query = Query::RESOURCE_PERMISSION_BY_USER;
pub const SHARE_LINK_PERMISSION: Query = Query::SHARE_LINK_PERMISSION;
pub const SHARE_LINK_SESSION_SOURCE: Query = Query::SHARE_LINK_SESSION_SOURCE;
pub const SHARE_SESSION_INSERT: Query = Query::SHARE_SESSION_INSERT;
pub const SHARE_SESSION_PERMISSION: Query = Query::SHARE_SESSION_PERMISSION;
pub const SHARE_SESSIONS_DELETE_BY_LINK: Query = Query::SHARE_SESSIONS_DELETE_BY_LINK;
pub const SHARE_SESSIONS_DELETE_EXPIRED: Query = Query::SHARE_SESSIONS_DELETE_EXPIRED;
pub const Q001: Query = Query::Q001;
pub const Q002: Query = Query::Q002;
pub const Q003: Query = Query::Q003;
@@ -325,6 +335,11 @@ mod tests {
Q033,
USER_LIST_WORKSPACES,
USER_LIST_PADS,
SHARE_LINK_SESSION_SOURCE,
SHARE_SESSION_INSERT,
SHARE_SESSION_PERMISSION,
SHARE_SESSIONS_DELETE_BY_LINK,
SHARE_SESSIONS_DELETE_EXPIRED,
] {
assert!(!get(DatabaseKind::Sqlite, query).is_empty());
assert!(!get(DatabaseKind::Postgres, query).is_empty());
+18 -3
View File
@@ -233,13 +233,13 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
}
Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, CAST(token AS CHAR CHARACTER SET utf8mb4) AS token, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
r#"SELECT token_hash, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
}
Query::RESOURCE_SHARING_PENDING => {
r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
}
Query::SHARE_LINK_INSERT => {
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"#
}
Query::SHARE_LINK_UPDATE => {
r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
@@ -251,7 +251,22 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#
}
Query::SHARE_LINK_PERMISSION => {
r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"#
r#"SELECT permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
}
Query::SHARE_LINK_SESSION_SOURCE => {
r#"SELECT token_hash, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
}
Query::SHARE_SESSION_INSERT => {
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?, ?)"#
}
Query::SHARE_SESSION_PERMISSION => {
r#"SELECT l.permission, CAST(s.expires_at AS CHAR CHARACTER SET utf8mb4) AS session_expires_at, CAST(l.expires_at AS CHAR CHARACTER SET utf8mb4) AS link_expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = ? AND s.resource_kind = ? AND s.resource_slug = ? AND l.revoked_at IS NULL"#
}
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
}
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
r#"DELETE FROM resource_share_sessions WHERE expires_at <= ?"#
}
Query::Q001 => {
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM workspaces WHERE slug = ?"#
+18 -3
View File
@@ -235,13 +235,13 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#
}
Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
r#"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
}
Query::RESOURCE_SHARING_PENDING => {
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"#
}
Query::SHARE_LINK_INSERT => {
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"#
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6)"#
}
Query::SHARE_LINK_UPDATE => {
r#"UPDATE resource_share_links SET permission = $1, expires_at = $2 WHERE token_hash = $3 AND resource_kind = $4 AND resource_slug = $5 AND revoked_at IS NULL"#
@@ -253,7 +253,22 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT permission FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"#
}
Query::SHARE_LINK_PERMISSION => {
r#"SELECT permission FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > $4)"#
r#"SELECT permission, expires_at FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL"#
}
Query::SHARE_LINK_SESSION_SOURCE => {
r#"SELECT token_hash, permission, expires_at FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL"#
}
Query::SHARE_SESSION_INSERT => {
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4, $5)"#
}
Query::SHARE_SESSION_PERMISSION => {
r#"SELECT l.permission, s.expires_at, l.expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = $1 AND s.resource_kind = $2 AND s.resource_slug = $3 AND l.revoked_at IS NULL"#
}
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = $1 AND resource_kind = $2 AND resource_slug = $3"#
}
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
r#"DELETE FROM resource_share_sessions WHERE expires_at <= $1"#
}
Query::Q001 => {
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = $1"#
+18 -3
View File
@@ -233,13 +233,13 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
}
Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
r#"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
}
Query::RESOURCE_SHARING_PENDING => {
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
}
Query::SHARE_LINK_INSERT => {
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"#
}
Query::SHARE_LINK_UPDATE => {
r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
@@ -251,7 +251,22 @@ pub fn get(query: Query) -> &'static str {
r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#
}
Query::SHARE_LINK_PERMISSION => {
r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"#
r#"SELECT permission, expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
}
Query::SHARE_LINK_SESSION_SOURCE => {
r#"SELECT token_hash, permission, expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
}
Query::SHARE_SESSION_INSERT => {
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?, ?)"#
}
Query::SHARE_SESSION_PERMISSION => {
r#"SELECT l.permission, s.expires_at, l.expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = ? AND s.resource_kind = ? AND s.resource_slug = ? AND l.revoked_at IS NULL"#
}
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
}
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
r#"DELETE FROM resource_share_sessions WHERE expires_at <= ?"#
}
Query::Q001 => {
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM workspaces WHERE slug = ?"#
+49
View File
@@ -50,6 +50,15 @@ pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Opt
cookie_value(headers, &name)
}
pub fn share_session_token<'a>(
headers: &'a HeaderMap,
kind: &str,
slug: &str,
) -> Option<&'a str> {
let name = share_session_cookie_name(kind, slug);
cookie_value(headers, &name)
}
pub fn session_cookie(token: &str, ttl_days: i64) -> HeaderValue {
secure_cookie(SESSION_COOKIE, token, ttl_days.saturating_mul(86_400))
}
@@ -104,6 +113,23 @@ pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> He
)
}
pub fn share_session_cookie(
kind: &str,
slug: &str,
token: &str,
max_age_seconds: i64,
) -> HeaderValue {
secure_cookie(
&share_session_cookie_name(kind, slug),
token,
max_age_seconds,
)
}
pub fn clear_share_session_cookie(kind: &str, slug: &str) -> HeaderValue {
clear_cookie(&share_session_cookie_name(kind, slug))
}
pub fn client_key(headers: &HeaderMap) -> String {
let forwarded_ip = header_ip(headers, "cf-connecting-ip")
.or_else(|| header_ip(headers, "x-real-ip"))
@@ -161,6 +187,11 @@ fn resource_cookie_name(kind: &str, slug: &str) -> String {
format!("__Host-rustpad_access_{}", hex::encode(&digest[..12]))
}
fn share_session_cookie_name(kind: &str, slug: &str) -> String {
let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes());
format!("__Host-rustpad_share_{}", hex::encode(&digest[..12]))
}
pub fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get(header::COOKIE)
@@ -318,6 +349,24 @@ mod tests {
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
}
#[test]
fn share_sessions_use_separate_scoped_opaque_cookies() {
let value = share_session_cookie("workspace", "private-space", "opaque", 600)
.to_str()
.unwrap();
assert!(value.starts_with("__Host-rustpad_share_"));
assert!(value.contains("=opaque;"));
assert!(value.contains("Max-Age=600"));
assert!(value.contains("HttpOnly"));
assert!(value.contains("Secure"));
assert!(value.contains("SameSite=Lax"));
let cleared = clear_share_session_cookie("workspace", "private-space")
.to_str()
.unwrap();
assert!(cleared.contains("Max-Age=0"));
}
#[test]
fn csrf_requires_matching_cookie_and_header() {
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
+132 -41
View File
@@ -196,37 +196,70 @@ async fn resource_permission_from_tokens(
state: &SharedState,
kind: &str,
slug: &str,
access_token: Option<&str>,
access_tokens: &[Option<&str>],
session_token: Option<&str>,
) -> Option<String> {
let access_permission = auth::share_link_permission(state, kind, slug, access_token)
let mut read_allowed = false;
for token in access_tokens {
match auth::share_access_permission(state, kind, slug, *token)
.await
.ok()
.flatten()
.as_deref()
{
Some("rw") => return Some("rw".into()),
Some("ro") => read_allowed = true,
_ => {}
}
}
match auth::account_resource_permission(state, kind, slug, session_token)
.await
.ok()
.flatten();
let session_permission = auth::account_resource_permission(state, kind, slug, session_token)
.await
.ok()
.flatten();
if access_permission.as_deref() == Some("rw") || session_permission.as_deref() == Some("rw") {
Some("rw".into())
} else if access_permission.as_deref() == Some("ro")
|| session_permission.as_deref() == Some("ro")
.flatten()
.as_deref()
{
Some("ro".into())
} else {
None
Some("rw") => Some("rw".into()),
Some("ro") if !read_allowed => Some("ro".into()),
_ if read_allowed => Some("ro".into()),
_ => None,
}
}
async fn anonymous_access_from_tokens(
async fn password_access_from_tokens(
state: &SharedState,
kind: &str,
slug: &str,
access_token: Option<&str>,
access_tokens: &[Option<&str>],
) -> bool {
crate::api::verify_resource_access_token(state, kind, slug, access_token)
.await
.unwrap_or(false)
for token in access_tokens {
if crate::api::verify_password_access_token(state, kind, slug, *token)
.await
.unwrap_or(false)
{
return true;
}
}
false
}
async fn current_resource_access(
state: &SharedState,
kind: &str,
slug: &str,
access_tokens: &[Option<&str>],
session_token: Option<&str>,
password_ok: bool,
public_unprotected: bool,
) -> (bool, bool) {
let permission =
resource_permission_from_tokens(state, kind, slug, access_tokens, session_token).await;
let password_token_ok = password_access_from_tokens(state, kind, slug, access_tokens).await;
let write_allowed = public_unprotected
|| password_ok
|| password_token_ok
|| permission.as_deref() == Some("rw");
let read_allowed = write_allowed || permission.as_deref() == Some("ro");
(read_allowed, write_allowed)
}
// Merged from note.rs
@@ -241,6 +274,9 @@ pub async fn upgrade(
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
}
let account_token = crate::security::session_token(&headers).map(str::to_owned);
let share_session_token =
crate::security::share_session_token(&headers, "workspace", &workspace_slug)
.map(str::to_owned);
let resource_token =
crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned);
let client_key = crate::security::client_key(&headers);
@@ -252,6 +288,7 @@ pub async fn upgrade(
workspace_slug,
note_slug,
account_token,
share_session_token,
resource_token,
client_key,
client_context,
@@ -265,7 +302,8 @@ async fn handle_socket(
workspace_slug: String,
note_slug: String,
cookie_session_token: Option<String>,
cookie_access_token: Option<String>,
cookie_share_session_token: Option<String>,
cookie_password_token: Option<String>,
client_key: String,
client_context: RequestClientContext,
) {
@@ -319,7 +357,11 @@ async fn handle_socket(
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "cookie")
.map(str::to_owned);
let access_token = explicit_access_token.or(cookie_access_token);
let external_tokens = [
explicit_access_token.as_deref(),
cookie_share_session_token.as_deref(),
cookie_password_token.as_deref(),
];
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
Ok(value) => value,
Err(message) => {
@@ -343,18 +385,17 @@ async fn handle_socket(
&state,
"workspace",
&workspace_slug,
access_token.as_deref(),
&external_tokens,
session_token.as_deref(),
)
.await;
let anonymous_token_ok = permission.is_none()
&& anonymous_access_from_tokens(
&state,
"workspace",
&workspace_slug,
access_token.as_deref(),
)
.await;
let anonymous_token_ok = password_access_from_tokens(
&state,
"workspace",
&workspace_slug,
&external_tokens,
)
.await;
let password_limit_key = format!("resource-password:{client_key}:workspace:{workspace_slug}");
let password_attempted = password
.as_deref()
@@ -410,10 +451,17 @@ async fn handle_socket(
let _ = send_error(&mut socket, "Invalid password").await;
return;
}
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none());
let public_unprotected = workspace.is_private == 0 && workspace.password_hash.is_none();
let (_, write_allowed) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
public_unprotected,
)
.await;
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
if send(
&mut socket,
@@ -443,6 +491,9 @@ async fn handle_socket(
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
access_refresh.tick().await;
let (mut sender, mut receiver) = socket.split();
if send_split(
&mut sender,
@@ -462,7 +513,17 @@ async fn handle_socket(
incoming=receiver.next()=>match incoming {
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Update{content,owner_map})=>{
if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
let (read_allowed, current_write_allowed) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
public_unprotected,
).await;
if !read_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; break; }
if !current_write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into());
match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await {
@@ -480,12 +541,42 @@ async fn handle_socket(
},
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;}
},
update=updates.recv()=>match update {
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
_=access_refresh.tick()=>{
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
public_unprotected,
).await;
if !read_allowed {
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
}
},
update=updates.recv()=>{
let (read_allowed, _) = current_resource_access(
&state,
"workspace",
&workspace_slug,
&external_tokens,
session_token.as_deref(),
password_ok,
public_unprotected,
).await;
if !read_allowed {
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
}
match update {
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
}
}
}
}
+75 -16
View File
@@ -44,6 +44,8 @@ pub async fn upgrade_pad(
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
}
let account_token = crate::security::session_token(&headers).map(str::to_owned);
let share_session_token =
crate::security::share_session_token(&headers, "pad", &slug).map(str::to_owned);
let resource_token = crate::security::resource_token(&headers, "pad", &slug).map(str::to_owned);
let client_key = crate::security::client_key(&headers);
let client_context = RequestClientContext::from_headers(&headers, &client_key);
@@ -53,6 +55,7 @@ pub async fn upgrade_pad(
state,
slug,
account_token,
share_session_token,
resource_token,
client_key,
client_context,
@@ -64,7 +67,8 @@ async fn handle_pad_socket(
state: SharedState,
slug: String,
cookie_session_token: Option<String>,
cookie_access_token: Option<String>,
cookie_share_session_token: Option<String>,
cookie_password_token: Option<String>,
client_key: String,
client_context: RequestClientContext,
) {
@@ -117,7 +121,11 @@ async fn handle_pad_socket(
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "cookie")
.map(str::to_owned);
let access_token = explicit_access_token.or(cookie_access_token);
let external_tokens = [
explicit_access_token.as_deref(),
cookie_share_session_token.as_deref(),
cookie_password_token.as_deref(),
];
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
Ok(value) => value,
Err(message) => {
@@ -141,12 +149,12 @@ async fn handle_pad_socket(
&state,
"pad",
&slug,
access_token.as_deref(),
&external_tokens,
session_token.as_deref(),
)
.await;
let anonymous_token_ok = permission.is_none()
&& anonymous_access_from_tokens(&state, "pad", &slug, access_token.as_deref()).await;
let anonymous_token_ok =
password_access_from_tokens(&state, "pad", &slug, &external_tokens).await;
let password_limit_key = format!("resource-password:{client_key}:pad:{slug}");
let password_attempted = password
.as_deref()
@@ -210,10 +218,17 @@ async fn handle_pad_socket(
.await;
return;
}
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none());
let public_unprotected = pad.is_private == 0 && pad.password_hash.is_none();
let (_, write_allowed) = current_resource_access(
&state,
"pad",
&slug,
&external_tokens,
session_token.as_deref(),
password_ok,
public_unprotected,
)
.await;
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
if send_pad(
&mut socket,
@@ -242,6 +257,9 @@ async fn handle_pad_socket(
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
access_refresh.tick().await;
let (mut sender, mut receiver) = socket.split();
if send_pad_split(
&mut sender,
@@ -260,7 +278,18 @@ async fn handle_pad_socket(
tokio::select! {
incoming=receiver.next()=>match incoming{
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
Ok(ClientMessage::Update{content,owner_map})=>{
let (read_allowed, current_write_allowed) = current_resource_access(
&state,
"pad",
&slug,
&external_tokens,
session_token.as_deref(),
password_ok,
public_unprotected,
).await;
if !read_allowed { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;break; }
if !current_write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into());
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
@@ -280,12 +309,42 @@ async fn handle_pad_socket(
Some(Ok(_))=>{},
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
},
update=updates.recv()=>match update{
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
_=access_refresh.tick()=>{
let (read_allowed, _) = current_resource_access(
&state,
"pad",
&slug,
&external_tokens,
session_token.as_deref(),
password_ok,
public_unprotected,
).await;
if !read_allowed {
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
}
},
update=updates.recv()=>{
let (read_allowed, _) = current_resource_access(
&state,
"pad",
&slug,
&external_tokens,
session_token.as_deref(),
password_ok,
public_unprotected,
).await;
if !read_allowed {
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
break;
}
match update {
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
}
}
}
}