Skip to main content

max / makenotwork

Audit coverage push + docengine assumptions feature Test coverage (driven by _meta/remediation_todo.md gaps + cargo llvm-cov): MNW server +83 lib tests (1440 → 1504): - payments/checkout_metadata.rs +19 (FanPlus/CreatorTier/AppSync from_session) - payments/webhooks.rs +5 (is_full_refund boundary) - email/templates/notifications.rs +18 (local CapturingTransport mock) - routes/api/internal/cli_features.rs +16 (extracted features_for_project_type, slug_from_title; verification token shape) - scanning/archive.rs +7 (URL-encoded/absolute/null-byte traversal, magic-byte nested-archive detection for ZIP/gzip/7z/RAR) - tests/workflows/stripe_webhooks.rs +5 (fan_plus cascade branches in subscription_updated/deleted, invoice_payment_succeeded/failed) - tests/workflows/rate_limiting.rs: #[ignore] under fast-tests; documented --ignored --test-threads=1 run-locally recipe in file header multithreaded +80 lib tests (36 → 116): - templates/public.rs +10 + new Pagination::offset() method, dedup'd inline pagination math across views.rs/thread.rs/moderation.rs - routes/forum/posts.rs +15 (extracted find_quote_refs, extract_preceding_quote_text, compute_quote_hash) - routes/forum/actions.rs +12 (extracted check_footnote_permission, check_endorsement_permission + denial enums) - routes/helpers.rs +28 (validate_title/body boundary, role wrappers, new community_state_denial_message, parse_duration/uuid) - internal_auth.rs +15 (extracted compute_internal_signature, verify_internal_signature; clock-injectable; pins HMAC format and >60s window) - src/link_preview.rs: LinkPreviewFetcher enum (Http/Noop) on AppState; eliminates tokio::spawn leakage and cargo-mutants timeouts in tests docengine +26 boundary tests (tempfile dev-dep added): - code_spans.rs +6 (arithmetic boundary on space-fill formula) - render.rs +3 (? and # arms of has_dangerous_scheme, strip_raw_html toggle) - directives.rs +3 (alert-close loop bound, strip arms, UI-caption arithmetic) - doc_loader.rs +14 (tempdir fixtures for load/get/search_index/ resolve_ui_examples) synckit-client +5 (decrypt_change_multi_key key-selection arms) PoM +33: - alerts.rs: extracted 4 priority helpers + backup_status_detail; +5 tests - checks/http.rs +11 (drift detection, staleness boundary, range edges) - checks/whois.rs +12 (TLD coverage, first-match guards, alt expiry names) - checks/tls.rs +4 (days_remaining boundary, cert field population) Parallel in-flight (user-driven): - docengine: new `assumptions` feature (toml-driven {{key}} substitution in markdown), new filter system, doc_loader gains pre_process hook, code_spans gated behind mentions/test features - server/site-docs/public/{about,guide,support}/*: migrated tiers/pricing to {{assumption}} placeholders - server bumped to 0.5.22, deploy.sh updates - server/docs/testnot_work.md: burn-in preview environment plan Server line coverage measured at 67.45% via cargo-llvm-cov.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-16 19:06 UTC
Commit: d2997986237c2b4ddb471d06c3769080e1c2c5e8
Parent: 7f3e772
52 files changed, +5002 insertions, -238 deletions
@@ -1891,7 +1891,7 @@
1891 1891
1892 1892 [[package]]
1893 1893 name = "docengine"
1894 - version = "0.3.1"
1894 + version = "0.3.4"
1895 1895 dependencies = [
1896 1896 "ammonia",
1897 1897 "pulldown-cmark",
@@ -3508,7 +3508,7 @@
3508 3508
3509 3509 [[package]]
3510 3510 name = "makenotwork"
3511 - version = "0.5.19"
3511 + version = "0.5.22"
3512 3512 dependencies = [
3513 3513 "anyhow",
3514 3514 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.5.19"
3 + version = "0.5.22"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -101,7 +101,7 @@
101 101 metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
102 102
103 103 # Markdown rendering + documentation engine
104 - docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls"] }
104 + docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls", "assumptions"] }
105 105
106 106 # Tag standard
107 107 tagtree = { path = "../shared/tagtree" }
@@ -35,58 +35,78 @@
35 35 StatusCode::SERVICE_UNAVAILABLE.into_response()
36 36 })?;
37 37
38 - let timestamp_str = req
38 + let timestamp_header = req
39 39 .headers()
40 40 .get("X-Internal-Timestamp")
41 41 .and_then(|v| v.to_str().ok())
42 - .ok_or_else(|| {
43 - (StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp").into_response()
44 - })?
45 - .to_string();
46 -
47 - let signature = req
42 + .map(str::to_string);
43 + let signature_header = req
48 44 .headers()
49 45 .get("X-Internal-Signature")
50 46 .and_then(|v| v.to_str().ok())
51 - .ok_or_else(|| {
52 - (StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature").into_response()
53 - })?
54 - .to_string();
47 + .map(str::to_string);
55 48
56 - // Verify timestamp freshness
57 - let timestamp: i64 = timestamp_str.parse().map_err(|_| {
58 - (StatusCode::UNAUTHORIZED, "Invalid timestamp").into_response()
59 - })?;
60 -
61 - let now = chrono::Utc::now().timestamp();
62 - if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
63 - return Err(
64 - (StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future")
65 - .into_response(),
66 - );
67 - }
68 -
69 - // Read body
70 49 let body = Bytes::from_request(req, state).await.map_err(|e| {
71 50 tracing::error!(error = %e, "failed to read request body");
72 51 StatusCode::BAD_REQUEST.into_response()
73 52 })?;
74 53
75 - // Verify HMAC
76 - let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(&body).unwrap_or(""));
77 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
78 - .expect("HMAC-SHA256 accepts any key length");
79 - mac.update(message.as_bytes());
80 - let expected = hex::encode(mac.finalize().into_bytes());
81 -
82 - if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
83 - return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response());
84 - }
54 + verify_internal_signature(
55 + secret,
56 + timestamp_header.as_deref(),
57 + signature_header.as_deref(),
58 + &body,
59 + chrono::Utc::now().timestamp(),
60 + )
61 + .map_err(|(status, msg)| (status, msg).into_response())?;
85 62
86 63 Ok(InternalAuth(body))
87 64 }
88 65 }
89 66
67 + /// Compute the hex-encoded HMAC-SHA256 signature for an internal request.
68 + /// `secret` may be any length (HMAC-SHA256 accepts any key length).
69 + pub(crate) fn compute_internal_signature(secret: &str, timestamp_str: &str, body: &[u8]) -> String {
70 + let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(body).unwrap_or(""));
71 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
72 + .expect("HMAC-SHA256 accepts any key length");
73 + mac.update(message.as_bytes());
74 + hex::encode(mac.finalize().into_bytes())
75 + }
76 +
77 + /// Pure verification: validate timestamp freshness against `now_unix`, then
78 + /// recompute the signature and constant-time compare.
79 + ///
80 + /// Headers are passed as `Option<&str>` so callers can extract them with any
81 + /// strategy (axum `HeaderMap`, manual `Bytes`, tests).
82 + pub(crate) fn verify_internal_signature(
83 + secret: &str,
84 + timestamp_header: Option<&str>,
85 + signature_header: Option<&str>,
86 + body: &[u8],
87 + now_unix: i64,
88 + ) -> Result<(), (StatusCode, &'static str)> {
89 + let timestamp_str = timestamp_header
90 + .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
91 + let signature = signature_header
92 + .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
93 +
94 + let timestamp: i64 = timestamp_str
95 + .parse()
96 + .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
97 +
98 + if (now_unix - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
99 + return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future"));
100 + }
101 +
102 + let expected = compute_internal_signature(secret, timestamp_str, body);
103 +
104 + if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
105 + return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
106 + }
107 + Ok(())
108 + }
109 +
90 110 /// Verify HMAC-SHA256 headers on an internal request (for GET endpoints without a body extractor).
91 111 pub fn verify_hmac_headers(
92 112 state: &AppState,
@@ -102,36 +122,20 @@
102 122 (StatusCode::SERVICE_UNAVAILABLE, "Service unavailable")
103 123 })?;
104 124
105 - let timestamp_str = headers
125 + let timestamp_header = headers
106 126 .get("X-Internal-Timestamp")
107 - .and_then(|v| v.to_str().ok())
108 - .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
109 -
110 - let signature = headers
127 + .and_then(|v| v.to_str().ok());
128 + let signature_header = headers
111 129 .get("X-Internal-Signature")
112 - .and_then(|v| v.to_str().ok())
113 - .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
130 + .and_then(|v| v.to_str().ok());
114 131
115 - let timestamp: i64 = timestamp_str
116 - .parse()
117 - .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
118 -
119 - let now = chrono::Utc::now().timestamp();
120 - if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
121 - return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future"));
122 - }
123 -
124 - let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(body).unwrap_or(""));
125 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
126 - .expect("HMAC-SHA256 accepts any key length");
127 - mac.update(message.as_bytes());
128 - let expected = hex::encode(mac.finalize().into_bytes());
129 -
130 - if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
131 - return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
132 - }
133 -
134 - Ok(())
132 + verify_internal_signature(
133 + secret,
134 + timestamp_header,
135 + signature_header,
136 + body,
137 + chrono::Utc::now().timestamp(),
138 + )
135 139 }
136 140
137 141 /// Constant-time byte comparison to prevent timing attacks.
@@ -174,4 +178,165 @@
174 178
175 179 assert!(constant_time_eq(sig.as_bytes(), expected.as_bytes()));
176 180 }
181 +
182 + // ── compute_internal_signature pins HMAC message construction ──
183 +
184 + #[test]
185 + fn signature_is_64_hex_chars() {
186 + let sig = compute_internal_signature("secret", "100", b"body");
187 + assert_eq!(sig.len(), 64, "SHA-256 hex is 64 chars");
188 + assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
189 + }
190 +
191 + #[test]
192 + fn signature_changes_with_secret() {
193 + // Pins that the secret feeds into the MAC key.
194 + let s1 = compute_internal_signature("alpha", "100", b"body");
195 + let s2 = compute_internal_signature("beta", "100", b"body");
196 + assert_ne!(s1, s2);
197 + }
198 +
199 + #[test]
200 + fn signature_changes_with_timestamp() {
201 + // Pins the `format!("{}\n{}", timestamp_str, body)` ordering — a
202 + // mutation that drops the timestamp or swaps the order would make
203 + // these two signatures match.
204 + let s1 = compute_internal_signature("secret", "100", b"body");
205 + let s2 = compute_internal_signature("secret", "101", b"body");
206 + assert_ne!(s1, s2);
207 + }
208 +
209 + #[test]
210 + fn signature_changes_with_body() {
211 + let s1 = compute_internal_signature("secret", "100", b"hello");
212 + let s2 = compute_internal_signature("secret", "100", b"hello!");
213 + assert_ne!(s1, s2);
214 + }
215 +
216 + #[test]
217 + fn signature_separator_is_newline_not_concat() {
218 + // Pins `format!("{}\n{}", ...)` — without the `\n`, "1" + "00body"
219 + // would collide with "10" + "0body".
220 + let collision_a = compute_internal_signature("secret", "1", b"00body");
221 + let collision_b = compute_internal_signature("secret", "10", b"0body");
222 + assert_ne!(
223 + collision_a, collision_b,
224 + "missing newline separator allows length-ambiguity collision"
225 + );
226 + }
227 +
228 + // ── verify_internal_signature freshness + signature check ──
229 +
230 + fn valid(secret: &str, ts: &str, body: &[u8]) -> String {
231 + compute_internal_signature(secret, ts, body)
232 + }
233 +
234 + #[test]
235 + fn verify_accepts_valid_signature_at_now() {
236 + let secret = "s";
237 + let body = b"abc";
238 + let ts = "1000";
239 + let sig = valid(secret, ts, body);
240 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1000).is_ok());
241 + }
242 +
243 + #[test]
244 + fn verify_rejects_wrong_signature() {
245 + let secret = "s";
246 + let body = b"abc";
247 + let ts = "1000";
248 + // Tamper with one hex char.
249 + let mut sig = valid(secret, ts, body);
250 + let first = sig.remove(0);
251 + sig.insert(0, if first == '0' { '1' } else { '0' });
252 + let (status, _) =
253 + verify_internal_signature(secret, Some(ts), Some(&sig), body, 1000).unwrap_err();
254 + assert_eq!(status, StatusCode::UNAUTHORIZED);
255 + }
256 +
257 + #[test]
258 + fn verify_rejects_wrong_secret() {
259 + let body = b"abc";
260 + let ts = "1000";
261 + let sig = valid("real-secret", ts, body);
262 + assert!(
263 + verify_internal_signature("wrong-secret", Some(ts), Some(&sig), body, 1000).is_err()
264 + );
265 + }
266 +
267 + #[test]
268 + fn verify_rejects_tampered_body() {
269 + let secret = "s";
270 + let ts = "1000";
271 + let sig = valid(secret, ts, b"original");
272 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), b"tampered", 1000).is_err());
273 + }
274 +
275 + #[test]
276 + fn verify_at_window_boundary_accepts_inside_rejects_outside() {
277 + // Pins `(now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS` (60s).
278 + // Exactly at the boundary (abs diff == 60) must be accepted (since `>`
279 + // is strict). One second past must be rejected.
280 + let secret = "s";
281 + let body = b"abc";
282 + let ts = "1000";
283 + let sig = valid(secret, ts, body);
284 +
285 + // diff = 60 → accepted
286 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1060).is_ok());
287 + // diff = 61 → rejected (too old)
288 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1061).is_err());
289 + // diff = -60 → accepted
290 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 940).is_ok());
291 + // diff = -61 → rejected (too far in future)
292 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 939).is_err());
293 + }
294 +
295 + #[test]
296 + fn verify_rejects_missing_timestamp_header() {
297 + let secret = "s";
298 + let body = b"abc";
299 + let sig = valid(secret, "1000", body);
300 + let (status, msg) =
301 + verify_internal_signature(secret, None, Some(&sig), body, 1000).unwrap_err();
302 + assert_eq!(status, StatusCode::UNAUTHORIZED);
303 + assert!(msg.contains("Timestamp"));
304 + }
305 +
306 + #[test]
307 + fn verify_rejects_missing_signature_header() {
308 + let (status, msg) =
309 + verify_internal_signature("s", Some("1000"), None, b"abc", 1000).unwrap_err();
310 + assert_eq!(status, StatusCode::UNAUTHORIZED);
311 + assert!(msg.contains("Signature"));
312 + }
313 +
314 + #[test]
315 + fn verify_rejects_unparseable_timestamp() {
316 + let (status, msg) =
317 + verify_internal_signature("s", Some("not-an-int"), Some("zz"), b"", 1000).unwrap_err();
318 + assert_eq!(status, StatusCode::UNAUTHORIZED);
319 + assert!(msg.contains("Invalid timestamp"));
320 + }
321 +
322 + #[test]
323 + fn verify_check_order_missing_timestamp_first() {
324 + // Both headers missing: timestamp check fires first.
325 + let (_, msg) = verify_internal_signature("s", None, None, b"", 1000).unwrap_err();
326 + assert!(msg.contains("Timestamp"), "expected timestamp msg first, got: {msg}");
327 + }
328 +
329 + #[test]
330 + fn verify_check_order_freshness_before_signature() {
331 + // A stale timestamp must reject even when the (otherwise valid) sig
332 + // matches. Catches a mutation that runs the freshness check after
333 + // signature verification.
334 + let secret = "s";
335 + let body = b"abc";
336 + let ts = "1000";
337 + let sig = valid(secret, ts, body);
338 + let (_, msg) =
339 + verify_internal_signature(secret, Some(ts), Some(&sig), body, 9999).unwrap_err();
340 + assert!(msg.contains("Timestamp"), "expected freshness msg, got: {msg}");
341 + }
177 342 }
@@ -20,7 +20,8 @@
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 + /// Link preview fetcher. `LinkPreviewFetcher::Http` in production with an
24 + /// SSRF-safe redirect policy; `LinkPreviewFetcher::Noop` in tests.
25 + pub link_preview: link_preview::LinkPreviewFetcher,
25 26 pub s3: Option<Arc<storage::S3Storage>>,
26 27 }
@@ -114,6 +114,24 @@
114 114 .expect("failed to build preview HTTP client")
115 115 }
116 116
117 + /// Strategy for fetching link previews. The `Noop` variant lets tests skip
118 + /// real HTTP without monkey-patching `tokio::spawn`; production constructs
119 + /// `Http(build_preview_client())`.
120 + #[derive(Clone)]
121 + pub enum LinkPreviewFetcher {
122 + Http(reqwest::Client),
123 + Noop,
124 + }
125 +
126 + impl LinkPreviewFetcher {
127 + pub async fn fetch(&self, url: &str) -> Option<(Option<String>, Option<String>)> {
128 + match self {
129 + Self::Http(client) => fetch_og_metadata(client, url).await,
130 + Self::Noop => None,
131 + }
132 + }
133 + }
134 +
117 135 /// Fetch OpenGraph metadata from a URL. Returns `(og:title, og:description)`.
118 136 /// Best-effort: returns None on any error (timeout, too large, parse failure).
119 137 #[tracing::instrument(skip_all)]
@@ -356,4 +374,11 @@
356 374 assert!(validate_url("http://8.8.8.8"));
357 375 assert!(validate_url("https://93.184.216.34"));
358 376 }
377 +
378 + #[tokio::test]
379 + async fn noop_fetcher_returns_none_without_network() {
380 + let fetcher = LinkPreviewFetcher::Noop;
381 + // Any URL — would be a public host in production but here we expect no I/O.
382 + assert!(fetcher.fetch("https://example.com").await.is_none());
383 + }
359 384 }
@@ -64,7 +64,9 @@
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 + link_preview: multithreaded::link_preview::LinkPreviewFetcher::Http(
68 + multithreaded::link_preview::build_preview_client(),
69 + ),
68 70 s3,
69 71 };
70 72
M pom/src/alerts.rs +103 -14