Skip to main content

max / multithreaded

Auth, uploads, link preview, forum actions, and deploy cleanup Auth and route improvements. Upload handling updates. Link preview fixes. Forum action refinements. Deploy env files consolidated. Test harness updates.
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-18 20:51 UTC
Commit: 504f3ea8937af5fe4a1aa8b40d7ff1ec1b47548a
Parent: 778e053
18 files changed, +185 insertions, -42 deletions
M .gitignore +4
@@ -15,5 +15,9 @@
15 15 # macOS
16 16 .DS_Store
17 17
18 + # Deploy environment files
19 + deploy/env.*
20 + !deploy/env.example
21 +
18 22 # Release artifacts
19 23 dist/
M Cargo.lock +3 -3
@@ -2060,14 +2060,14 @@
2060 2060
2061 2061 [[package]]
2062 2062 name = "mt-core"
2063 - version = "0.3.0"
2063 + version = "0.3.1"
2064 2064 dependencies = [
2065 2065 "chrono",
2066 2066 ]
2067 2067
2068 2068 [[package]]
2069 2069 name = "mt-db"
2070 - version = "0.3.0"
2070 + version = "0.3.1"
2071 2071 dependencies = [
2072 2072 "chrono",
2073 2073 "serde",
@@ -2095,7 +2095,7 @@
2095 2095
2096 2096 [[package]]
2097 2097 name = "multithreaded"
2098 - version = "0.3.0"
2098 + version = "0.3.1"
2099 2099 dependencies = [
2100 2100 "ammonia",
2101 2101 "askama",
M Cargo.toml +1 -1
@@ -7,7 +7,7 @@
7 7 default-members = ["."]
8 8
9 9 [workspace.package]
10 - version = "0.3.0"
10 + version = "0.3.1"
11 11 edition = "2024"
12 12 license-file = "LICENSE"
13 13
M todo.md +3 -1
@@ -1,6 +1,6 @@
1 1 # Multithreaded — Todo
2 2
3 - Done: All pre-beta phases (0-11, 13-24). 222 tests (150 integration + 56 unit lib + 16 unit mt-core). v0.2.5. Audit grade: A. Deployed to hetzner+astra (forums.makenot.work). All 20 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set).
3 + Done: All pre-beta phases (0-11, 13-24). 222 tests (150 integration + 56 unit lib + 16 unit mt-core). v0.3.0. Audit grade: A (Run 8). Deployed to hetzner+astra (forums.makenot.work). All 20 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set).
4 4
5 5 Completed work archived in [todo_done.md](todo_done.md).
6 6
@@ -8,6 +8,8 @@
8 8
9 9 No remaining pre-beta items. Only deferred post-beta items below.
10 10
11 + Run 8 audit items resolved. Moved to `todo_done.md`.
12 +
11 13 ---
12 14
13 15 ## Deferred (Post-Beta)
@@ -342,3 +342,6 @@
342 342
343 343 ### MNW Forums tab
344 344 - [x] Already implemented in MNW — dashboard tab, HTMX partial, MT_BASE_URL config
345 +
346 + ## Run 8 Audit Items (Mar 2026)
347 + - [x] Remove unnecessary `data.clone()` in uploads.rs (saves up to 5MB allocation per image upload)
M src/auth.rs +4 -1
@@ -312,12 +312,15 @@
312 312 display_name: info.display_name,
313 313 };
314 314 session_user.save_to_session(&session).await;
315 + if let Err(e) = session.cycle_id().await {
316 + tracing::warn!(error = %e, "Failed to cycle session ID");
317 + }
315 318 tracing::info!("session saved, redirecting to /");
316 319
317 320 Redirect::to("/")
318 321 }
319 322
320 - /// `GET /auth/logout` — flush session, redirect home.
323 + /// `POST /auth/logout` — flush session, redirect home.
321 324 #[tracing::instrument(skip_all)]
322 325 pub async fn logout(session: Session) -> impl IntoResponse {
323 326 let _ = session.flush().await;
M src/lib.rs +2
@@ -20,5 +20,7 @@
20 20 pub db: PgPool,
21 21 pub config: Config,
22 22 pub http: reqwest::Client,
23 + /// SSRF-safe client for link preview fetching (validates URLs on redirects).
24 + pub preview_http: reqwest::Client,
23 25 pub s3: Option<Arc<storage::S3Storage>>,
24 26 }
@@ -1,6 +1,7 @@
1 1 //! Link preview — server-side OpenGraph metadata fetch for post URLs.
2 2
3 3 use pulldown_cmark::{Event, Parser, Tag};
4 + use reqwest::header::CONTENT_TYPE;
4 5
5 6 /// Maximum number of URLs to extract per post.
6 7 const MAX_URLS: usize = 3;
@@ -8,6 +9,50 @@
8 9 /// Maximum response body size to read (1 MB).
9 10 const MAX_BODY_SIZE: usize = 1_048_576;
10 11
12 + /// Validate that a URL is safe to fetch (no SSRF to internal networks).
13 + fn validate_url(url: &str) -> bool {
14 + let lower = url.to_ascii_lowercase();
15 + if !lower.starts_with("http://") && !lower.starts_with("https://") {
16 + return false;
17 + }
18 + let host_part = lower
19 + .strip_prefix("http://")
20 + .or_else(|| lower.strip_prefix("https://"))
21 + .unwrap_or("");
22 + let host_and_port = host_part.split('/').next().unwrap_or("");
23 + let host = if host_and_port.starts_with('[') {
24 + host_and_port
25 + .split(']')
26 + .next()
27 + .map(|s| format!("{}]", s))
28 + .unwrap_or_default()
29 + } else {
30 + host_and_port.split(':').next().unwrap_or("").to_string()
31 + };
32 + let host = host.as_str();
33 + if host == "localhost"
34 + || host == "127.0.0.1"
35 + || host == "[::1]"
36 + || host == "0.0.0.0"
37 + || host.starts_with("10.")
38 + || host.starts_with("192.168.")
39 + || host.starts_with("169.254.")
40 + || host.starts_with("[fd")
41 + || host.starts_with("[fe80:")
42 + {
43 + return false;
44 + }
45 + // Block 172.16.0.0/12
46 + if let Some(rest) = host.strip_prefix("172.")
47 + && let Some(second) = rest.split('.').next()
48 + && let Ok(n) = second.parse::<u8>()
49 + && (16..=31).contains(&n)
50 + {
51 + return false;
52 + }
53 + true
54 + }
55 +
11 56 /// Extract unique http/https URLs from markdown text via pulldown_cmark link parsing.
12 57 /// Returns at most `MAX_URLS` URLs.
13 58 pub fn extract_urls(input: &str) -> Vec<String> {
@@ -32,6 +77,20 @@
32 77 urls
33 78 }
34 79
80 + /// Build a reqwest client for link preview fetching with SSRF-safe redirect policy.
81 + pub fn build_preview_client() -> reqwest::Client {
82 + reqwest::Client::builder()
83 + .redirect(reqwest::redirect::Policy::custom(|attempt| {
84 + if !validate_url(attempt.url().as_str()) || attempt.previous().len() >= 5 {
85 + attempt.stop()
86 + } else {
87 + attempt.follow()
88 + }
89 + }))
90 + .build()
91 + .expect("failed to build preview HTTP client")
92 + }
93 +
35 94 /// Fetch OpenGraph metadata from a URL. Returns `(og:title, og:description)`.
36 95 /// Best-effort: returns None on any error (timeout, too large, parse failure).
37 96 #[tracing::instrument(skip_all)]
@@ -39,6 +98,10 @@
39 98 http: &reqwest::Client,
40 99 url: &str,
41 100 ) -> Option<(Option<String>, Option<String>)> {
101 + if !validate_url(url) {
102 + return None;
103 + }
104 +
42 105 let resp = http
43 106 .get(url)
44 107 .timeout(std::time::Duration::from_secs(5))
@@ -51,6 +114,14 @@
51 114 return None;
52 115 }
53 116
117 + // Only fetch HTML content
118 + if let Some(ct) = resp.headers().get(CONTENT_TYPE) {
119 + let ct_str = ct.to_str().unwrap_or("");
120 + if !ct_str.starts_with("text/html") {
121 + return None;
122 + }
123 + }
124 +
54 125 // Read body in chunks, capping at MAX_BODY_SIZE
55 126 let mut body = Vec::new();
56 127 let mut stream = resp;
@@ -187,4 +258,75 @@
187 258 let html = "<html><head></head></html>";
188 259 assert_eq!(extract_html_title(html), None);
189 260 }
261 +
262 + // -- validate_url tests --
263 +
264 + #[test]
265 + fn validate_url_allows_https() {
266 + assert!(validate_url("https://example.com"));
267 + assert!(validate_url("https://example.com/path?q=1"));
268 + }
269 +
270 + #[test]
271 + fn validate_url_allows_http() {
272 + assert!(validate_url("http://example.com"));
273 + }
274 +
275 + #[test]
276 + fn validate_url_blocks_non_http_schemes() {
277 + assert!(!validate_url("ftp://example.com"));
278 + assert!(!validate_url("file:///etc/passwd"));
279 + assert!(!validate_url("javascript:alert(1)"));
280 + assert!(!validate_url("data:text/html,<h1>hi</h1>"));
281 + }
282 +
283 + #[test]
284 + fn validate_url_blocks_localhost() {
285 + assert!(!validate_url("http://localhost"));
286 + assert!(!validate_url("http://localhost:8080"));
287 + assert!(!validate_url("http://127.0.0.1"));
288 + assert!(!validate_url("http://127.0.0.1:3000"));
289 + assert!(!validate_url("http://0.0.0.0"));
290 + assert!(!validate_url("http://[::1]"));
291 + assert!(!validate_url("http://[::1]:8080"));
292 + }
293 +
294 + #[test]
295 + fn validate_url_blocks_private_10() {
296 + assert!(!validate_url("http://10.0.0.1"));
297 + assert!(!validate_url("http://10.255.255.255"));
298 + }
299 +
300 + #[test]
301 + fn validate_url_blocks_private_192_168() {
302 + assert!(!validate_url("http://192.168.0.1"));
303 + assert!(!validate_url("http://192.168.1.100:8080"));
304 + }
305 +
306 + #[test]
307 + fn validate_url_blocks_private_172_16() {
308 + assert!(!validate_url("http://172.16.0.1"));
309 + assert!(!validate_url("http://172.31.255.255"));
310 + // 172.15 and 172.32 are public
311 + assert!(validate_url("http://172.15.0.1"));
312 + assert!(validate_url("http://172.32.0.1"));
313 + }
314 +
315 + #[test]
316 + fn validate_url_blocks_link_local() {
317 + assert!(!validate_url("http://169.254.0.1"));
318 + assert!(!validate_url("http://169.254.169.254")); // AWS metadata
319 + }
320 +
321 + #[test]
322 + fn validate_url_blocks_ipv6_private() {
323 + assert!(!validate_url("http://[fd00::1]"));
324 + assert!(!validate_url("http://[fe80::1]"));
325 + }
326 +
327 + #[test]
328 + fn validate_url_allows_public_ips() {
329 + assert!(validate_url("http://8.8.8.8"));
330 + assert!(validate_url("https://93.184.216.34"));
331 + }
190 332 }
@@ -64,6 +64,7 @@
64 64 .connect_timeout(std::time::Duration::from_secs(5))
65 65 .build()
66 66 .expect("failed to build HTTP client"),
67 + preview_http: multithreaded::link_preview::build_preview_client(),
67 68 s3,
68 69 };
69 70