From 4c3d638e03cbfaa29647c8d7b0a99e8870c157ed Mon Sep 17 00:00:00 2001 From: xwid Date: Thu, 8 Jan 2026 10:18:54 +0100 Subject: [PATCH 1/9] check HTTP method in authz in case of writing --- pubky-homeserver/src/client_server/layers/authz.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubky-homeserver/src/client_server/layers/authz.rs b/pubky-homeserver/src/client_server/layers/authz.rs index 2b10a1a95..2ed25eb34 100644 --- a/pubky-homeserver/src/client_server/layers/authz.rs +++ b/pubky-homeserver/src/client_server/layers/authz.rs @@ -118,7 +118,7 @@ async fn authorize( // if method == Method::GET { // return Ok(()); // } - } else { + } else if method == Method::PUT { tracing::warn!( "Writing to directories other than '/pub/' is forbidden: {}/{}. Access forbidden", public_key, From 40d733fcd4bb0b3cd7921420a4bd2094658b674a Mon Sep 17 00:00:00 2001 From: xwid Date: Fri, 9 Jan 2026 12:02:18 +0100 Subject: [PATCH 2/9] update error msg --- pubky-homeserver/src/client_server/layers/authz.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubky-homeserver/src/client_server/layers/authz.rs b/pubky-homeserver/src/client_server/layers/authz.rs index 2ed25eb34..e8eaf68c2 100644 --- a/pubky-homeserver/src/client_server/layers/authz.rs +++ b/pubky-homeserver/src/client_server/layers/authz.rs @@ -120,12 +120,12 @@ async fn authorize( // } } else if method == Method::PUT { tracing::warn!( - "Writing to directories other than '/pub/' is forbidden: {}/{}. Access forbidden", + "Access to non-/pub/ paths is forbidden: {}/{}. Access forbidden", public_key, path ); return Err(HttpError::forbidden_with_message( - "Writing to directories other than '/pub/' is forbidden", + "Access to non-/pub/ paths is forbidden", )); } From 080fcf28d9baa2280b5714481ed9f7164cd5f45e Mon Sep 17 00:00:00 2001 From: xwid Date: Fri, 9 Jan 2026 12:04:46 +0100 Subject: [PATCH 3/9] Revert "check HTTP method in authz in case of writing" This reverts commit 4c3d638e03cbfaa29647c8d7b0a99e8870c157ed. --- pubky-homeserver/src/client_server/layers/authz.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubky-homeserver/src/client_server/layers/authz.rs b/pubky-homeserver/src/client_server/layers/authz.rs index e8eaf68c2..f7dfa92fc 100644 --- a/pubky-homeserver/src/client_server/layers/authz.rs +++ b/pubky-homeserver/src/client_server/layers/authz.rs @@ -118,7 +118,7 @@ async fn authorize( // if method == Method::GET { // return Ok(()); // } - } else if method == Method::PUT { + } else { tracing::warn!( "Access to non-/pub/ paths is forbidden: {}/{}. Access forbidden", public_key, From 5ffc4c95baac6bd1148112b4e45dbd6e99472b6e Mon Sep 17 00:00:00 2001 From: xwid Date: Fri, 9 Jan 2026 12:06:01 +0100 Subject: [PATCH 4/9] adjust msg --- pubky-homeserver/src/client_server/layers/authz.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubky-homeserver/src/client_server/layers/authz.rs b/pubky-homeserver/src/client_server/layers/authz.rs index f7dfa92fc..182757ffd 100644 --- a/pubky-homeserver/src/client_server/layers/authz.rs +++ b/pubky-homeserver/src/client_server/layers/authz.rs @@ -120,7 +120,7 @@ async fn authorize( // } } else { tracing::warn!( - "Access to non-/pub/ paths is forbidden: {}/{}. Access forbidden", + "Access to non-/pub/ paths is forbidden: {}/{}.", public_key, path ); From 48fa5c35693e47aa81febf4ed1ed771f23afc457 Mon Sep 17 00:00:00 2001 From: xwid Date: Tue, 13 Jan 2026 09:59:04 +0100 Subject: [PATCH 5/9] add idea to gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6c7086508..54826c484 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ target/ .vscode/* gcs-service-account.json .DS_Store -./claude/* \ No newline at end of file +./claude/* +.idea/ \ No newline at end of file From feff3d59f805a41379780034198b57bd80d56e30 Mon Sep 17 00:00:00 2001 From: xwid Date: Wed, 14 Jan 2026 10:25:25 +0100 Subject: [PATCH 6/9] authz unit tests --- .../src/client_server/layers/authz.rs | 340 +++++++++++++++++- pubky-homeserver/src/shared/http_error.rs | 12 + 2 files changed, 346 insertions(+), 6 deletions(-) diff --git a/pubky-homeserver/src/client_server/layers/authz.rs b/pubky-homeserver/src/client_server/layers/authz.rs index 182757ffd..fa77c040c 100644 --- a/pubky-homeserver/src/client_server/layers/authz.rs +++ b/pubky-homeserver/src/client_server/layers/authz.rs @@ -1,5 +1,6 @@ use crate::client_server::{extractors::PubkyHost, AppState}; use crate::persistence::sql::session::{SessionRepository, SessionSecret}; +use crate::persistence::sql::SqlDb; use crate::shared::{HttpError, HttpResult}; use axum::http::Method; use axum::response::IntoResponse; @@ -71,6 +72,7 @@ where None => { tracing::warn!("Pubky Host is missing in request. Authorization failed."); return Ok(HttpError::new_with_message( + // todo: should we return 403 here ? StatusCode::NOT_FOUND, "Pubky Host is missing", ) @@ -87,7 +89,14 @@ where }; // Authorize the request - if let Err(e) = authorize(&state, req.method(), cookies, pubky.public_key(), path).await + if let Err(e) = authorize( + &state.sql_db, + req.method(), + cookies, + pubky.public_key(), + path, + ) + .await { return Ok(e.into_response()); } @@ -98,9 +107,9 @@ where } } -/// Authorize write (PUT or DELETE) for Public paths. +/// Authorize request. async fn authorize( - state: &AppState, + sql_db: &SqlDb, method: &Method, cookies: &Cookies, public_key: &PublicKey, @@ -143,9 +152,7 @@ async fn authorize( }; let session = - match SessionRepository::get_by_secret(&session_secret, &mut state.sql_db.pool().into()) - .await - { + match SessionRepository::get_by_secret(&session_secret, &mut sql_db.pool().into()).await { Ok(session) => session, Err(sqlx::Error::RowNotFound) => { tracing::warn!( @@ -202,3 +209,324 @@ pub fn session_secret_from_cookies( .map(|c| c.value().to_string())?; SessionSecret::new(value).ok() } + +#[cfg(test)] +pub mod tests { + use std::str::FromStr; + + use pkarr::{Keypair, PublicKey}; + use pubky_common::capabilities::{Capabilities, Capability}; + use reqwest::{Method, StatusCode}; + use tower_cookies::{Cookie, Cookies}; + + use crate::{ + client_server::layers::authz::authorize, + persistence::sql::{session::SessionRepository, user::UserRepository, SqlDb}, + }; + + const PUBKEY: &str = "o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy"; + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_non_pub_paths() { + let methods = vec![ + Method::GET, + Method::PUT, + Method::POST, + Method::DELETE, + Method::PATCH, + ]; + + let db = SqlDb::test().await; + let cookies = Cookies::default(); + let public_key = PublicKey::from_str(PUBKEY).unwrap(); + + for method in methods { + let result = authorize(&db, &method, &cookies, &public_key, "/test").await; + match result { + Err(http) => { + assert_eq!( + http.status(), + StatusCode::FORBIDDEN, + "Method {:?} on /test", + method + ); + assert_eq!( + http.detail(), + Some("Access to non-/pub/ paths is forbidden"), + "Error message should indicate non-/pub/ path forbidden" + ); + } + Ok(_) => panic!("Expected error for method {:?} on /test, got Ok", method), + } + } + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_pub_paths() { + let test_cases = vec![ + (Method::GET, None), + (Method::HEAD, None), + (Method::PUT, Some(StatusCode::UNAUTHORIZED)), + (Method::POST, Some(StatusCode::UNAUTHORIZED)), + (Method::DELETE, Some(StatusCode::UNAUTHORIZED)), + (Method::PATCH, Some(StatusCode::UNAUTHORIZED)), + ]; + + let db = SqlDb::test().await; + let cookies = Cookies::default(); + let public_key = PublicKey::from_str(PUBKEY).unwrap(); + + for (method, expected_error) in test_cases { + let result = authorize(&db, &method, &cookies, &public_key, "/pub/test").await; + match expected_error { + Some(expected_status) => match result { + Err(http) => { + assert_eq!( + http.status(), + expected_status, + "Method {:?} on /pub/test", + method + ); + if expected_status == StatusCode::UNAUTHORIZED { + assert_eq!( + http.detail(), + Some("No session secret found in cookies"), + "Error message should indicate missing session cookie" + ); + } + } + Ok(_) => panic!( + "Expected error {:?} for method {:?} on /pub/test, got Ok", + expected_status, method + ), + }, + None => { + if let Err(http) = result { + panic!( + "Expected Ok for method {:?} on /pub/test, got error {:?}", + method, + http.status() + ); + } + } + } + } + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_session_path_allows_all_methods() { + let methods = vec![ + Method::GET, + Method::PUT, + Method::POST, + Method::DELETE, + Method::PATCH, + ]; + + let db = SqlDb::test().await; + let cookies = Cookies::default(); + let public_key = PublicKey::from_str(PUBKEY).unwrap(); + + for method in methods { + let result = authorize(&db, &method, &cookies, &public_key, "/session").await; + assert!( + result.is_ok(), + "Method {:?} on /session should be allowed without auth", + method + ); + } + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_valid_session_with_write_capability() { + let db = SqlDb::test().await; + let keypair = Keypair::random(); + let public_key = keypair.public_key(); + + // Create user + UserRepository::create(&public_key, &mut db.pool().into()) + .await + .unwrap(); + + // Create session with root capability (write access to /pub/) + let capabilities = Capabilities::builder().cap(Capability::root()).finish(); + let user = UserRepository::get(&public_key, &mut db.pool().into()) + .await + .unwrap(); + let session_secret = + SessionRepository::create(user.id, &capabilities, &mut db.pool().into()) + .await + .unwrap(); + + // Create cookies with session secret + let cookies = Cookies::default(); + cookies.add(Cookie::new( + public_key.to_string(), + session_secret.to_string(), + )); + + // Test write operations should succeed + let write_methods = vec![Method::PUT, Method::POST, Method::DELETE, Method::PATCH]; + + for method in write_methods { + let result = authorize(&db, &method, &cookies, &public_key, "/pub/test.txt").await; + assert!( + result.is_ok(), + "Method {:?} on /pub/test.txt with valid session should succeed", + method + ); + } + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_session_pubkey_mismatch() { + let db = SqlDb::test().await; + let keypair = Keypair::random(); + let public_key = keypair.public_key(); + + // Create user and session + UserRepository::create(&public_key, &mut db.pool().into()) + .await + .unwrap(); + + let capabilities = Capabilities::builder().cap(Capability::root()).finish(); + let user = UserRepository::get(&public_key, &mut db.pool().into()) + .await + .unwrap(); + let session_secret = + SessionRepository::create(user.id, &capabilities, &mut db.pool().into()) + .await + .unwrap(); + + // Create cookies with session secret but use different public key + let different_keypair = Keypair::random(); + let different_public_key = different_keypair.public_key(); + + let cookies = Cookies::default(); + cookies.add(Cookie::new( + different_public_key.to_string(), + session_secret.to_string(), + )); + + // Should fail with unauthorized because pubkey doesn't match session + let result = authorize( + &db, + &Method::PUT, + &cookies, + &different_public_key, + "/pub/test.txt", + ) + .await; + + match result { + Err(http) => { + assert_eq!( + http.status(), + StatusCode::UNAUTHORIZED, + "Pubkey mismatch should return UNAUTHORIZED" + ); + assert_eq!( + http.detail(), + Some("SessionInfo public key does not match pubky-host"), + "Error message should indicate pubkey mismatch" + ); + } + Ok(_) => panic!("Expected UNAUTHORIZED for pubkey mismatch, got Ok"), + } + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_session_without_write_capability() { + let db = SqlDb::test().await; + let keypair = Keypair::random(); + let public_key = keypair.public_key(); + + // Create user + UserRepository::create(&public_key, &mut db.pool().into()) + .await + .unwrap(); + + // Create session with limited capability (only read access to specific path) + let capabilities = Capabilities::builder() + .cap(Capability::read("/pub/readonly/")) + .finish(); + let user = UserRepository::get(&public_key, &mut db.pool().into()) + .await + .unwrap(); + let session_secret = + SessionRepository::create(user.id, &capabilities, &mut db.pool().into()) + .await + .unwrap(); + + // Create cookies with session secret + let cookies = Cookies::default(); + cookies.add(Cookie::new( + public_key.to_string(), + session_secret.to_string(), + )); + + // Try to write to /pub/test.txt (should fail - no write capability) + let result = authorize(&db, &Method::PUT, &cookies, &public_key, "/pub/test.txt").await; + + match result { + Err(http) => { + assert_eq!( + http.status(), + StatusCode::FORBIDDEN, + "Write without write capability should return FORBIDDEN" + ); + assert_eq!( + http.detail(), + Some("Session does not have write access to path"), + "Error message should indicate missing write capability" + ); + } + Ok(_) => panic!("Expected FORBIDDEN for write without capability, got Ok"), + } + } + + #[tokio::test] + #[pubky_test_utils::test] + async fn test_invalid_session_secret_in_db() { + let db = SqlDb::test().await; + let keypair = Keypair::random(); + let public_key = keypair.public_key(); + + // Create user but no session + UserRepository::create(&public_key, &mut db.pool().into()) + .await + .unwrap(); + + // Create cookies with non-existent session secret (must be 26 chars) + let cookies = Cookies::default(); + cookies.add(Cookie::new( + public_key.to_string(), + "abcdefghijklmnopqrstuvwxyz", // 26 chars, valid format but not in DB + )); + + // Should fail with unauthorized because session doesn't exist in DB + let result = authorize(&db, &Method::PUT, &cookies, &public_key, "/pub/test.txt").await; + + match result { + Err(http) => { + assert_eq!( + http.status(), + StatusCode::UNAUTHORIZED, + "Invalid session secret should return UNAUTHORIZED" + ); + assert_eq!( + http.detail(), + Some("No session found for session secret"), + "Error message should indicate session not found in database" + ); + } + Ok(_) => panic!("Expected UNAUTHORIZED for invalid session secret, got Ok"), + } + } +} diff --git a/pubky-homeserver/src/shared/http_error.rs b/pubky-homeserver/src/shared/http_error.rs index fd03d7371..cf0843a98 100644 --- a/pubky-homeserver/src/shared/http_error.rs +++ b/pubky-homeserver/src/shared/http_error.rs @@ -30,6 +30,18 @@ impl HttpError { } } + /// Get the status code of the error. + #[cfg(test)] + pub fn status(&self) -> StatusCode { + self.status + } + + /// Get the detail message of the error. + #[cfg(test)] + pub fn detail(&self) -> Option<&str> { + self.detail.as_deref() + } + pub fn not_found() -> HttpError { Self::new_with_message(StatusCode::NOT_FOUND, "Not Found") } From 51ffd80bb4d01bc123c49da8bbaae606ebebf253 Mon Sep 17 00:00:00 2001 From: xwid Date: Wed, 14 Jan 2026 10:25:46 +0100 Subject: [PATCH 7/9] adjust sdk tests --- pubky-sdk/bindings/js/pkg/tests/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubky-sdk/bindings/js/pkg/tests/storage.ts b/pubky-sdk/bindings/js/pkg/tests/storage.ts index 7ff12d885..ffd8c1b24 100644 --- a/pubky-sdk/bindings/js/pkg/tests/storage.ts +++ b/pubky-sdk/bindings/js/pkg/tests/storage.ts @@ -177,7 +177,7 @@ test("forbidden: writing outside /pub returns 403", async (t) => { t.equal(getStatusCode(error), 403, "status code 403"); t.ok( String(error.message || "").includes( - "Writing to directories other than '/pub/'", + "Access to non-/pub/ paths is forbidden", ), "error message mentions /pub restriction", ); From 424aedd880a0d1c93445bbb86b1f27342034bc3c Mon Sep 17 00:00:00 2001 From: xwid Date: Wed, 14 Jan 2026 10:27:02 +0100 Subject: [PATCH 8/9] remove comment --- pubky-homeserver/src/client_server/layers/authz.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/pubky-homeserver/src/client_server/layers/authz.rs b/pubky-homeserver/src/client_server/layers/authz.rs index fa77c040c..a4adb27e7 100644 --- a/pubky-homeserver/src/client_server/layers/authz.rs +++ b/pubky-homeserver/src/client_server/layers/authz.rs @@ -72,7 +72,6 @@ where None => { tracing::warn!("Pubky Host is missing in request. Authorization failed."); return Ok(HttpError::new_with_message( - // todo: should we return 403 here ? StatusCode::NOT_FOUND, "Pubky Host is missing", ) From c4d2632b332a441069689af1e6ce6eea7fde4d75 Mon Sep 17 00:00:00 2001 From: xwid Date: Wed, 14 Jan 2026 10:59:50 +0100 Subject: [PATCH 9/9] fix tests after merge --- .../src/client_server/layers/authz.rs | 25 +++++++------------ .../src/persistence/sql/sql_db.rs | 2 +- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/pubky-homeserver/src/client_server/layers/authz.rs b/pubky-homeserver/src/client_server/layers/authz.rs index ca77095f8..37cf803b0 100644 --- a/pubky-homeserver/src/client_server/layers/authz.rs +++ b/pubky-homeserver/src/client_server/layers/authz.rs @@ -213,15 +213,14 @@ pub fn session_secret_from_cookies( pub mod tests { use std::str::FromStr; - use pkarr::{Keypair, PublicKey}; - use pubky_common::capabilities::{Capabilities, Capability}; - use reqwest::{Method, StatusCode}; - use tower_cookies::{Cookie, Cookies}; - use crate::{ client_server::layers::authz::authorize, persistence::sql::{session::SessionRepository, user::UserRepository, SqlDb}, }; + use pubky_common::capabilities::{Capabilities, Capability}; + use pubky_common::crypto::{Keypair, PublicKey}; + use reqwest::{Method, StatusCode}; + use tower_cookies::{Cookie, Cookies}; const PUBKEY: &str = "o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy"; @@ -238,7 +237,7 @@ pub mod tests { let db = SqlDb::test().await; let cookies = Cookies::default(); - let public_key = PublicKey::from_str(PUBKEY).unwrap(); + let public_key = PublicKey::try_from(PUBKEY).unwrap(); for method in methods { let result = authorize(&db, &method, &cookies, &public_key, "/test").await; @@ -363,10 +362,7 @@ pub mod tests { // Create cookies with session secret let cookies = Cookies::default(); - cookies.add(Cookie::new( - public_key.to_string(), - session_secret.to_string(), - )); + cookies.add(Cookie::new(public_key.z32(), session_secret.to_string())); // Test write operations should succeed let write_methods = vec![Method::PUT, Method::POST, Method::DELETE, Method::PATCH]; @@ -408,7 +404,7 @@ pub mod tests { let cookies = Cookies::default(); cookies.add(Cookie::new( - different_public_key.to_string(), + different_public_key.z32(), session_secret.to_string(), )); @@ -465,10 +461,7 @@ pub mod tests { // Create cookies with session secret let cookies = Cookies::default(); - cookies.add(Cookie::new( - public_key.to_string(), - session_secret.to_string(), - )); + cookies.add(Cookie::new(public_key.z32(), session_secret.to_string())); // Try to write to /pub/test.txt (should fail - no write capability) let result = authorize(&db, &Method::PUT, &cookies, &public_key, "/pub/test.txt").await; @@ -505,7 +498,7 @@ pub mod tests { // Create cookies with non-existent session secret (must be 26 chars) let cookies = Cookies::default(); cookies.add(Cookie::new( - public_key.to_string(), + public_key.z32(), "abcdefghijklmnopqrstuvwxyz", // 26 chars, valid format but not in DB )); diff --git a/pubky-homeserver/src/persistence/sql/sql_db.rs b/pubky-homeserver/src/persistence/sql/sql_db.rs index d69ad7da8..785dd2b67 100644 --- a/pubky-homeserver/src/persistence/sql/sql_db.rs +++ b/pubky-homeserver/src/persistence/sql/sql_db.rs @@ -82,7 +82,7 @@ impl Drop for TestDbDropper { } #[cfg(any(test, feature = "testing"))] -const DEFAULT_TEST_CONNECTION_STRING: &str = "postgres://localhost:5432/postgres"; +const DEFAULT_TEST_CONNECTION_STRING: &str = "postgres://postgres:postgres@localhost:5432/postgres"; #[cfg(any(test, feature = "testing"))] impl SqlDb {