This commit is contained in:
Andras Schmelczer 2026-05-14 22:07:14 +01:00
parent 084117cea8
commit a8de0a614d
36 changed files with 1329 additions and 522 deletions

View file

@ -9,6 +9,31 @@ use tracing::warn;
use crate::state::SharedState;
/// PocketBase API paths the frontend is allowed to reach via /pb/*.
/// Everything else (admins API, settings, logs, backups, collection schema,
/// arbitrary collection records like checkout_sessions/invites/short_urls)
/// is rejected at the proxy layer as defense-in-depth on top of PB's own
/// collection rules.
fn is_allowed_pb_path(path: &str) -> bool {
// Exact paths
if matches!(
path,
"/api/health" | "/api/oauth2-redirect" | "/api/realtime"
) {
return true;
}
// Prefix-allowed paths. The trailing slash is intentional — without it,
// `/api/collections/users` (the schema endpoint) would match.
const ALLOWED_PREFIXES: &[&str] = &[
"/api/collections/users/",
"/api/collections/saved_searches/",
"/api/files/",
];
ALLOWED_PREFIXES
.iter()
.any(|prefix| path.starts_with(prefix))
}
/// Dedicated HTTP client for proxying — does not follow redirects so 3xx
/// responses are passed through to the browser (needed for OAuth flows).
/// No overall timeout because SSE (Server-Sent Events) connections used by
@ -31,6 +56,13 @@ pub async fn proxy_to_pocketbase(
let path = req.uri().path();
let target_path = path.strip_prefix("/pb").unwrap_or(path);
if !is_allowed_pb_path(target_path) {
warn!(path = %target_path, "Rejected PocketBase proxy request to disallowed path");
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap();
}
let query = req
.uri()
.query()
@ -55,20 +87,9 @@ pub async fn proxy_to_pocketbase(
}
}
// Forward client IP so PocketBase rate-limits per-user, not per-server.
// Prefer existing X-Forwarded-For (from reverse proxy), fall back to X-Real-IP.
if let Some(xff) = req.headers().get("x-forwarded-for") {
builder = builder.header("X-Forwarded-For", xff.clone());
// First IP in the chain is the original client
if let Ok(s) = xff.to_str() {
if let Some(client_ip) = s.split(',').next().map(str::trim) {
builder = builder.header("X-Real-IP", client_ip);
}
}
} else if let Some(real_ip) = req.headers().get("x-real-ip") {
builder = builder.header("X-Forwarded-For", real_ip.clone());
builder = builder.header("X-Real-IP", real_ip.clone());
}
// Do not forward client-supplied X-Forwarded-For/X-Real-IP. PocketBase
// may use trusted proxy headers for rate limits, so accepting public
// values here lets callers choose their own source IP.
// Forward body
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {