fix2 tokens

This commit is contained in:
Mateusz Gruszczyński committed 2026-08-03 01:35:05 +02:00
1 parent 49dad1a5f4
commit e3ee6319b9
25 files changed
+1420 -223

No files matched your search

+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");
}
}