Skip to content

Commit 915f089

Browse files
committed
fix: security hardening and correctness fixes
1 parent dde31d2 commit 915f089

File tree

14 files changed

+150
-29
lines changed

14 files changed

+150
-29
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## [0.1.2] - 2026-03-12
4+
5+
### Added
6+
- Logout mechanism with nav bar button
7+
- Security headers on all admin responses (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
8+
- Login-specific rate limiting (10/min per IP, separate from general admin limit)
9+
- Periodic eviction of stale notification rate limiter entries
10+
11+
### Fixed
12+
- CSRF cookie no longer set as HttpOnly, allowing JS double-submit injection to work
13+
- Login cookie stores a SHA-256 derivative instead of the raw admin token
14+
- CSRF body size limit now uses configured `max_body_size` instead of hardcoded 10MB
15+
- Notification rate limiter no longer wastes per-project budget when global limit rejects
16+
- Discard stats flush no longer double-counts on partial DB write failures
17+
- Threshold alert state update failures are now logged instead of silently dropped
18+
319
## [0.1.1] - 2026-03-09
420

521
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stackpit"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
edition = "2021"
55
description = "Lightweight, self-hosted error tracking and event monitoring"
66
authors = ["Franz Geffke <mail@gofranz.com>"]

src/html/login.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ pub async fn handle_login(
4545
.as_ref()
4646
.is_some_and(|u| u.starts_with("https://"));
4747
let secure_flag = if secure { "; Secure" } else { "" };
48+
let hashed = crate::middleware::hash_token_for_cookie(&token);
4849
let cookie =
49-
format!("stackpit_token={token}; Path=/; SameSite=Strict; HttpOnly{secure_flag}");
50+
format!("stackpit_token={hashed}; Path=/; SameSite=Strict; HttpOnly{secure_flag}");
5051
let mut resp = axum::response::Redirect::to("/web/projects/").into_response();
5152
if let Ok(val) = cookie.parse() {
5253
resp.headers_mut().insert("set-cookie", val);
@@ -61,3 +62,20 @@ pub async fn handle_login(
6162
pub struct LoginForm {
6263
token: String,
6364
}
65+
66+
pub async fn handle_logout(State(state): State<AppState>) -> impl IntoResponse {
67+
let secure = state
68+
.config
69+
.server
70+
.external_url
71+
.as_ref()
72+
.is_some_and(|u| u.starts_with("https://"));
73+
let secure_flag = if secure { "; Secure" } else { "" };
74+
let cookie =
75+
format!("stackpit_token=; Path=/; SameSite=Strict; HttpOnly; Max-Age=0{secure_flag}");
76+
let mut resp = axum::response::Redirect::to("/web/login").into_response();
77+
if let Ok(val) = cookie.parse() {
78+
resp.headers_mut().insert("set-cookie", val);
79+
}
80+
resp
81+
}

src/html/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ pub fn routes() -> Router<AppState> {
316316
"/web/login",
317317
get(login::login_form).post(login::handle_login),
318318
)
319+
.route("/web/logout", post(login::handle_logout))
319320
// -- legacy redirects --
320321
.route(
321322
"/web/",

src/middleware/admin_auth.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,15 @@ pub async fn admin_auth_middleware(
5151
return next.run(req).await;
5252
}
5353

54-
// Try Bearer header first, then fall back to cookie
55-
let provided =
56-
extract_bearer_token(req.headers()).or_else(|| extract_auth_cookie(req.headers()));
57-
58-
let is_valid = match &provided {
59-
Some(p) => !p.is_empty() && p.as_bytes().ct_eq(expected.as_bytes()).into(),
60-
None => false,
54+
// Bearer header: compare raw token. Cookie: compare SHA-256 hash so
55+
// the raw admin token is never stored in the browser cookie jar.
56+
let is_valid = if let Some(ref bearer) = extract_bearer_token(req.headers()) {
57+
!bearer.is_empty() && bearer.as_bytes().ct_eq(expected.as_bytes()).into()
58+
} else if let Some(ref cookie_val) = extract_auth_cookie(req.headers()) {
59+
let expected_hash = super::hash_token_for_cookie(expected);
60+
!cookie_val.is_empty() && cookie_val.as_bytes().ct_eq(expected_hash.as_bytes()).into()
61+
} else {
62+
false
6163
};
6264

6365
if is_valid {

src/middleware/csrf.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ use crate::encoding::percent_decode;
88
const CSRF_COOKIE_NAME: &str = "csrf_token";
99
const CSRF_TOKEN_LEN: usize = 16; // 128-bit hex token
1010

11+
#[derive(Clone)]
12+
pub struct CsrfConfig {
13+
pub use_secure_cookies: bool,
14+
pub max_body_size: usize,
15+
}
16+
1117
fn generate_csrf_token() -> String {
1218
let mut buf = [0u8; CSRF_TOKEN_LEN];
1319
rand::fill(&mut buf);
@@ -29,7 +35,7 @@ fn extract_csrf_field(body: &[u8]) -> Option<String> {
2935
}
3036

3137
pub async fn csrf_middleware(
32-
State(use_secure_cookies): State<bool>,
38+
State(config): State<CsrfConfig>,
3339
req: axum::http::Request<axum::body::Body>,
3440
next: Next,
3541
) -> axum::response::Response {
@@ -42,7 +48,7 @@ pub async fn csrf_middleware(
4248
if is_post && is_web && !is_login {
4349
let cookie_token_for_check = cookie_token.clone();
4450
let (parts, body) = req.into_parts();
45-
let bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await {
51+
let bytes = match axum::body::to_bytes(body, config.max_body_size).await {
4652
Ok(b) => b,
4753
Err(_) => {
4854
return (axum::http::StatusCode::BAD_REQUEST, "request too large").into_response();
@@ -71,11 +77,14 @@ pub async fn csrf_middleware(
7177
let mut resp = next.run(req).await;
7278
if needs_cookie {
7379
let token = generate_csrf_token();
74-
let secure_flag = if use_secure_cookies { "; Secure" } else { "" };
75-
if let Ok(val) = format!(
76-
"{CSRF_COOKIE_NAME}={token}; Path=/web; HttpOnly; SameSite=Strict{secure_flag}"
77-
)
78-
.parse()
80+
let secure_flag = if config.use_secure_cookies {
81+
"; Secure"
82+
} else {
83+
""
84+
};
85+
if let Ok(val) =
86+
format!("{CSRF_COOKIE_NAME}={token}; Path=/web; SameSite=Strict{secure_flag}")
87+
.parse()
7988
{
8089
resp.headers_mut().append("set-cookie", val);
8190
}

src/middleware/mod.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,32 @@ mod csrf;
44
mod rate_limit;
55

66
pub use admin_auth::admin_auth_middleware;
7-
pub use csrf::csrf_middleware;
7+
pub use csrf::{csrf_middleware, CsrfConfig};
88
pub use rate_limit::{new_rate_limiter_state, rate_limit_middleware};
9+
10+
/// Derives a cookie-safe token from the admin token using SHA-256.
11+
pub fn hash_token_for_cookie(token: &str) -> String {
12+
use sha2::{Digest, Sha256};
13+
hex::encode(Sha256::digest(token.as_bytes()))
14+
}
15+
16+
pub async fn security_headers_middleware(
17+
req: axum::http::Request<axum::body::Body>,
18+
next: axum::middleware::Next,
19+
) -> axum::response::Response {
20+
let mut resp = next.run(req).await;
21+
let h = resp.headers_mut();
22+
h.insert("x-content-type-options", "nosniff".parse().unwrap());
23+
h.insert("x-frame-options", "DENY".parse().unwrap());
24+
h.insert(
25+
"referrer-policy",
26+
"strict-origin-when-cross-origin".parse().unwrap(),
27+
);
28+
h.insert(
29+
"content-security-policy",
30+
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'"
31+
.parse()
32+
.unwrap(),
33+
);
34+
resp
35+
}

src/middleware/rate_limit.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::collections::HashMap;
55
use std::sync::{Arc, Mutex};
66

77
const ADMIN_RATE_LIMIT: u32 = 120;
8+
const LOGIN_RATE_LIMIT: u32 = 10;
89
const ADMIN_RATE_WINDOW_SECS: u64 = 60;
910

1011
pub(crate) struct IpBucket {
@@ -61,7 +62,15 @@ fn check_rate_limit(
6162
inner.last_cleanup = now;
6263
}
6364

64-
let bucket = inner.buckets.entry(ip).or_insert(IpBucket {
65+
let is_login_post =
66+
req.uri().path() == "/web/login" && req.method() == axum::http::Method::POST;
67+
let (key, limit) = if is_login_post {
68+
(format!("{ip}:login"), LOGIN_RATE_LIMIT)
69+
} else {
70+
(ip, ADMIN_RATE_LIMIT)
71+
};
72+
73+
let bucket = inner.buckets.entry(key).or_insert(IpBucket {
6574
count: 0,
6675
window_start: now,
6776
});
@@ -71,7 +80,7 @@ fn check_rate_limit(
7180
bucket.window_start = now;
7281
}
7382

74-
if bucket.count >= ADMIN_RATE_LIMIT {
83+
if bucket.count >= limit {
7584
false
7685
} else {
7786
bucket.count += 1;

src/notify/rate_limit.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use dashmap::DashMap;
2+
use std::sync::atomic::{AtomicU64, Ordering};
23
use std::sync::Mutex;
34

45
/// A sliding window over 60 one-second buckets.
@@ -56,6 +57,7 @@ pub struct NotifyRateLimiter {
5657
global_window: Mutex<SlidingWindow>,
5758
project_limit: u32,
5859
global_limit: u32,
60+
last_cleanup: AtomicU64,
5961
}
6062

6163
impl NotifyRateLimiter {
@@ -65,12 +67,25 @@ impl NotifyRateLimiter {
6567
global_window: Mutex::new(SlidingWindow::new()),
6668
project_limit,
6769
global_limit,
70+
last_cleanup: AtomicU64::new(0),
6871
}
6972
}
7073

7174
/// Returns `true` if the notification is allowed, `false` if rate-limited.
7275
pub fn check_and_record(&self, project_id: u64, now_secs: u64) -> bool {
73-
// Check per-project limit
76+
// Periodic cleanup: evict stale project entries every 2 minutes
77+
let last = self.last_cleanup.load(Ordering::Relaxed);
78+
if now_secs.saturating_sub(last) >= 120
79+
&& self
80+
.last_cleanup
81+
.compare_exchange(last, now_secs, Ordering::Relaxed, Ordering::Relaxed)
82+
.is_ok()
83+
{
84+
self.project_windows
85+
.retain(|_, w| now_secs.saturating_sub(w.current_second) < 120);
86+
}
87+
88+
// Check per-project limit (don't increment yet -- wait for global check)
7489
if self.project_limit > 0 {
7590
let mut entry = self
7691
.project_windows
@@ -81,7 +96,6 @@ impl NotifyRateLimiter {
8196
if window.count() >= self.project_limit {
8297
return false;
8398
}
84-
window.increment(now_secs);
8599
}
86100

87101
// Check global limit
@@ -94,6 +108,13 @@ impl NotifyRateLimiter {
94108
global.increment(now_secs);
95109
}
96110

111+
// Both limits passed -- now safe to increment per-project
112+
if self.project_limit > 0 {
113+
if let Some(mut entry) = self.project_windows.get_mut(&project_id) {
114+
entry.value_mut().increment(now_secs);
115+
}
116+
}
117+
97118
true
98119
}
99120
}

0 commit comments

Comments
 (0)