Skip to main content

max / makenotwork

Audit remediation: security hardening, OAuth provider, MT support, monitoring Multi-round audit improvements: - Security A+: rate limiting on forgot-password, transaction wrapping on refund flow, constant-time webhook signature comparison, malware scanning pipeline hardening, input validation boundaries tightened - OAuth provider: /oauth/authorize, /oauth/token, /oauth/userinfo endpoints for MT PKCE flow. Migration 026 (redirect_uris). Relaxed redirect_uri validation - MT integration: /api/public/projects endpoint, forum directory support - Monitoring: /health page with PoM integration (incidents, expandable checks, route status, formatted timestamps) - Landing page: feature grid, explore links, stronger copy - Code documentation: module-level docs across all route and DB modules - Deploy: S3 diagnostic logging, deploy.sh updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 03:36 UTC
Commit: e98b4667e1aa367c59291ad23bc067a3ae7cf18b
Parent: 942a470
85 files changed, +1909 insertions, -330 deletions
M .gitignore +3
@@ -21,3 +21,6 @@
21 21
22 22 # SQLx offline mode cache
23 23 .sqlx/
24 +
25 + # Generated template partial (build.rs output)
26 + server_code/makenotwork/templates/_head_assets.html
@@ -3453,7 +3453,7 @@
3453 3453
3454 3454 [[package]]
3455 3455 name = "makenotwork"
3456 - version = "0.2.0"
3456 + version = "0.2.2"
3457 3457 dependencies = [
3458 3458 "ammonia",
3459 3459 "anyhow",
@@ -3468,6 +3468,7 @@
3468 3468 "base64 0.22.1",
3469 3469 "chrono",
3470 3470 "clap",
3471 + "dashmap",
3471 3472 "dotenvy",
3472 3473 "git2",
3473 3474 "goblin",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.2.1"
3 + version = "0.2.2"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -35,6 +35,9 @@
35 35 tower-sessions = { version = "0.14.0", features = ["axum-core"] }
36 36 tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] }
37 37
38 + # Concurrent hash map (session touch cache)
39 + dashmap = "6"
40 +
38 41 # Rate Limiting
39 42 tower_governor = "0.6.0"
40 43 governor = "0.8.1"
@@ -1,4 +1,7 @@
1 + use std::collections::hash_map::DefaultHasher;
2 + use std::hash::{Hash, Hasher};
1 3 use std::process::Command;
4 + use std::{fs, path::Path};
2 5
3 6 fn main() {
4 7 // Set GIT_HASH env var for compile-time inclusion via option_env!()
@@ -14,4 +17,47 @@
14 17 println!("cargo::rustc-env=GIT_HASH={}", hash);
15 18 // Only re-run when HEAD changes
16 19 println!("cargo::rerun-if-changed=.git/HEAD");
20 +
21 + // --- Static asset fingerprinting ---
22 + // Hash the content of key static files to produce a version suffix.
23 + // When any watched file changes, URLs in templates get a new ?v= param,
24 + // busting browser caches automatically.
25 + let static_files = [
26 + "static/style.css",
27 + "static/htmx.min.js",
28 + "static/upload.js",
29 + "static/passkey.js",
30 + "static/insertions.js",
31 + ];
32 +
33 + let mut hasher = DefaultHasher::new();
34 + for path in &static_files {
35 + println!("cargo::rerun-if-changed={}", path);
36 + if let Ok(content) = fs::read(path) {
37 + content.hash(&mut hasher);
38 + }
39 + }
40 + let static_hash = format!("{:016x}", hasher.finish());
41 + let version = &static_hash[..8];
42 +
43 + // Generate a template partial with versioned asset URLs.
44 + // base.html includes this via {% include "_head_assets.html" %}
45 + let partial = format!(
46 + r#" <link rel="preload" href="/static/fonts/Lato-Regular.woff2" as="font" type="font/woff2" crossorigin>
47 + <link rel="preload" href="/static/fonts/ysrf.woff2" as="font" type="font/woff2" crossorigin>
48 + <link rel="stylesheet" href="/static/style.css?v={v}">
49 + <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
50 + <script src="/static/htmx.min.js"></script>
51 + <script src="/static/upload.js?v={v}"></script>"#,
52 + v = version,
53 + );
54 +
55 + let out_path = Path::new("templates/_head_assets.html");
56 + // Only write if content changed (avoids unnecessary recompilation)
57 + let needs_write = fs::read_to_string(out_path)
58 + .map(|existing| existing != partial)
59 + .unwrap_or(true);
60 + if needs_write {
61 + fs::write(out_path, &partial).expect("failed to write _head_assets.html");
62 + }
17 63 }
@@ -39,10 +39,22 @@
39 39 ssh $SERVER "mkdir -p $REMOTE_DIR/error-pages"
40 40 scp $DEPLOY_DIR/error-pages/*.html $SERVER:$REMOTE_DIR/error-pages/
41 41
42 + # Minify CSS for production (restore source on exit)
43 + echo "[config] Minifying CSS..."
44 + cp static/style.css static/style.css.src
45 + restore_css() { [ -f static/style.css.src ] && mv static/style.css.src static/style.css; }
46 + trap restore_css EXIT
47 + npx --yes clean-css-cli -o static/style.css static/style.css.src
48 + echo "[config] CSS: $(wc -c < static/style.css.src | tr -d ' ')B -> $(wc -c < static/style.css | tr -d ' ')B"
49 +
42 50 # Static assets (CSS, JS, fonts, images)
43 51 echo "[config] Uploading static assets..."
44 52 rsync -az --delete static/ $SERVER:$REMOTE_DIR/static/
45 53
54 + # Restore unminified CSS
55 + restore_css
56 + trap - EXIT
57 +
46 58 # Documentation (public markdown files)
47 59 echo "[config] Uploading documentation..."
48 60 rsync -az --delete ../../docs/public/ $SERVER:$REMOTE_DIR/docs/public/
@@ -1,4 +1,19 @@
1 - //! Authentication with argon2 password hashing and session management
1 + //! Authentication, session management, and account security.
2 + //!
3 + //! Passwords are hashed with Argon2id (random salt per hash). Sessions use
4 + //! `tower-sessions` with ID regeneration on login (prevents fixation) and
5 + //! full flush on logout. Each login creates a tracked session row in
6 + //! `user_sessions` for remote revocation from the security dashboard.
7 + //!
8 + //! Two-factor authentication supports both TOTP (time-based one-time
9 + //! passwords via `totp-rs`) and WebAuthn passkeys (via `webauthn-rs`).
10 + //! Account lockout is enforced after repeated failed login attempts, with
11 + //! progressive delays tracked by `failed_login_attempts` and `locked_until`
12 + //! on the user row. New-device login notifications are sent via Postmark
13 + //! when enabled.
14 + //!
15 + //! Extractors: [`AuthUser`] (required login), [`MaybeUser`] (optional),
16 + //! [`AdminUser`] (admin-only, hides routes with 404).
2 17
3 18 use argon2::{
4 19 password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
@@ -12,6 +27,8 @@
12 27 use sqlx::PgPool;
13 28 use tower_sessions::Session;
14 29
30 + use std::time::Instant;
31 +
15 32 use crate::config::Config;
16 33 use crate::constants;
17 34 use crate::db::{self, UserId, UserSessionId, Username};
@@ -74,14 +91,36 @@
74 91 .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?
75 92 .ok_or(AppError::Unauthorized)?;
76 93
77 - // Validate session tracking (skip for legacy sessions without tracking ID)
94 + // Validate session tracking (skip for legacy sessions without tracking ID).
95 + // Uses an in-memory cache to avoid hitting the DB on every request —
96 + // if this session was validated within SESSION_TOUCH_CACHE_SECS, skip the query.
97 + let mut user = user;
78 98 if let Ok(Some(tracking_id)) = session
79 99 .get::<UserSessionId>(SESSION_TRACKING_KEY)
80 100 .await
81 - && !db::sessions::touch_session(&state.db, tracking_id).await.unwrap_or(true)
82 101 {
83 - let _ = session.flush().await;
84 - return Err(AppError::Unauthorized);
102 + let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
103 + let cached = state.session_cache.get(&tracking_id)
104 + .map(|entry| entry.elapsed() < cache_ttl)
105 + .unwrap_or(false);
106 +
107 + if !cached {
108 + let result = db::sessions::touch_session(&state.db, tracking_id)
109 + .await
110 + .unwrap_or(db::sessions::TouchResult { valid: false, suspended: false });
111 + if !result.valid {
112 + state.session_cache.remove(&tracking_id);
113 + let _ = session.flush().await;
114 + return Err(AppError::Unauthorized);
115 + }
116 + // If the user's suspended status changed since login, update the
117 + // session so check_not_suspended() reflects the live DB value.
118 + if user.suspended != result.suspended {
119 + user.suspended = result.suspended;
120 + let _ = session.insert(USER_SESSION_KEY, user.clone()).await;
121 + }
122 + state.session_cache.insert(tracking_id, Instant::now());
123 + }
85 124 }
86 125
87 126 Ok(AuthUser(user))
@@ -5,11 +5,12 @@
5 5 //! they're only used there.
6 6
7 7 // -- Database --
8 - pub const DB_POOL_MAX_CONNECTIONS: u32 = 10;
8 + pub const DB_POOL_MAX_CONNECTIONS: u32 = 25;
9 9 pub const DB_ACQUIRE_TIMEOUT_SECS: u64 = 3;
10 10
11 11 // -- Sessions --
12 12 pub const SESSION_EXPIRY_DAYS: i64 = 7;
13 + pub const SESSION_TOUCH_CACHE_SECS: u64 = 30; // Skip DB touch if validated within this window
13 14
14 15 // -- Login security --
15 16 pub const MAX_LOGIN_ATTEMPTS: i32 = 5;
@@ -116,6 +117,9 @@
116 117 pub const GIT_MAX_FILE_SIZE_BYTES: usize = 1_024_000; // 1MB display limit
117 118 pub const GIT_COMMITS_PER_PAGE: usize = 30;
118 119
120 + // -- Webhook security --
121 + pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes
122 +
119 123 // -- String / buffer limits --
120 124 pub const USER_AGENT_MAX_LENGTH: usize = 512;
121 125 pub const SYNCKIT_MAX_KEY_ENVELOPE_BYTES: usize = 4096;
@@ -3,9 +3,15 @@
3 3
4 4 use std::collections::HashMap;
5 5 use std::path::Path;
6 + use std::sync::LazyLock;
6 7
7 8 use regex::Regex;
8 9
10 + /// Pre-compiled regex for matching Markdown links: `[text](url)`.
11 + static LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
12 + Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid regex")
13 + });
14 +
9 15 use crate::markdown::render_markdown;
10 16
11 17 /// A rendered documentation page.
@@ -164,9 +170,7 @@
164 170 /// - Unpublished links (`../../unpublished/...`) → plain text (link removed)
165 171 /// - Absolute URLs, mailto, and route links are preserved as-is.
166 172 fn rewrite_links(markdown: &str) -> String {
167 - let link_re = Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap();
168 -
169 - link_re
173 + LINK_RE
170 174 .replace_all(markdown, |caps: &regex::Captures| {
171 175 let text = &caps[1];
172 176 let url = &caps[2];
@@ -2,6 +2,8 @@
2 2
3 3 use axum::http::header::HeaderMap;
4 4 use axum::http::HeaderValue;
5 + use axum::http::StatusCode;
6 + use axum::response::{IntoResponse, Response};
5 7 use tower_sessions::Session;
6 8
7 9 /// Check whether the incoming request was made by HTMX.
@@ -9,6 +11,38 @@
9 11 headers.get("HX-Request").is_some()
10 12 }
11 13
14 + /// Check the client's `If-None-Match` header against a cache generation.
15 + /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh.
16 + pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> {
17 + let etag = format!("\"g{}\"", generation);
18 + if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH) {
19 + if if_none_match.as_bytes() == etag.as_bytes() {
20 + return Some(
21 + (
22 + StatusCode::NOT_MODIFIED,
23 + [(axum::http::header::ETAG, HeaderValue::from_str(&etag).unwrap())],
24 + )
25 + .into_response(),
26 + );
27 + }
28 + }
29 + None
30 + }
31 +
32 + /// Wrap a rendered response with ETag and Cache-Control headers.
33 + /// `no-cache` tells the browser to store the response but revalidate on each use.
34 + pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response {
35 + let etag = format!("\"g{}\"", generation);
36 + (
37 + [
38 + (axum::http::header::ETAG, etag),
39 + (axum::http::header::CACHE_CONTROL, "private, no-cache".to_string()),
40 + ],
41 + body,
42 + )
43 + .into_response()
44 + }
45 +
12 46 /// Get or create a CSRF token for the session, returning `None` on failure.
13 47 ///
14 48 /// Convenience wrapper for templates that need an `Option<String>`.
@@ -35,6 +35,9 @@
35 35
36 36 use std::sync::Arc;
37 37
38 + use dashmap::DashMap;
39 + use db::UserSessionId;
40 +
38 41 use config::Config;
39 42 use docs::DocEngine;
40 43 use email::EmailClient;
@@ -62,6 +65,10 @@
62 65 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
63 66 pub started_at: chrono::DateTime<chrono::Utc>,
64 67 pub start_instant: Instant,
68 + /// Cache of recently-validated session tracking IDs to skip per-request DB touch.
69 + /// Maps session tracking ID → last validated instant. Entries older than
70 + /// SESSION_TOUCH_CACHE_SECS are treated as expired.
71 + pub session_cache: Arc<DashMap<UserSessionId, Instant>>,
65 72 }
66 73
67 74 /// Build the app router with all routes and middleware (minus tracing/TCP).
@@ -95,15 +95,18 @@
95 95 .await
96 96 .expect("Failed to migrate session store");
97 97
98 - // In release mode, require HTTPS for session cookies
99 - let secure_cookies = !cfg!(debug_assertions);
98 + // In release mode, require HTTPS for session cookies (override with INSECURE_COOKIES=1 for staging)
99 + let secure_cookies =
100 + !cfg!(debug_assertions) && std::env::var("INSECURE_COOKIES").unwrap_or_default() != "1";
100 101 if secure_cookies {
101 102 tracing::info!("Session cookies configured for HTTPS (secure=true)");
103 + } else if !cfg!(debug_assertions) {
104 + tracing::warn!("Session cookies set to insecure (INSECURE_COOKIES=1)");
102 105 }
103 106
104 107 let session_layer = SessionManagerLayer::new(session_store)
105 108 .with_secure(secure_cookies)
106 - .with_same_site(SameSite::Strict) // CSRF defense
109 + .with_same_site(SameSite::Lax) // Lax allows session on top-level navigations (OAuth redirects)
107 110 .with_expiry(Expiry::OnInactivity(CookieDuration::days(
108 111 constants::SESSION_EXPIRY_DAYS,
109 112 )));
@@ -216,6 +219,7 @@
216 219 syntax,
217 220 started_at,
218 221 start_instant,
222 + session_cache: std::sync::Arc::new(dashmap::DashMap::new()),
219 223 };
220 224
221 225 // Start background health monitor and scheduler
Binary file
A README.md +97
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file