Skip to content

Commit 9e728fa

Browse files
committed
bugfixes
## Fix: backup validation, logging rotation, and DB consistency improvements This PR completes updates to `backup.rs` and `logging.rs`, along with several fixes across the database layer. --- ### Summary of Changes #### `db/posts.rs` - **#13 – Constant-time comparison** - Replaced custom `constant_time_eq` with `subtle::ConstantTimeEq` - Use: `a.ct_eq(b).into()` - Added import: `use subtle::ConstantTimeEq as _` - Removed hand-rolled implementation - **#16 – Poll vote race condition** - Updated `cast_vote` query: - JOINs `polls` table - Adds expiry guard: ```sql AND (p.expires_at IS NULL OR p.expires_at > unixepoch()) ``` - Prevents race between handler validation and INSERT - **#25 – `edit_window_secs` semantics** - `0` → no time limit (skips check via `Option`) - Negative → fallback to 300s - Positive → used as-is --- #### `handlers/admin/backup.rs` - **#19 – SQLite validation** - Added magic byte check (`b"SQLite format 3\0"`) after extracting `chan.db` - Applied to: - `admin_restore` - `restore_saved_board_backup` - Invalid DB now returns `400 Bad Request` before `rusqlite` usage - **#30 – Duplicate encoding logic removed** - Removed inner `encode_q` / `nibble` definitions - Introduced single module-level `encode_q` - Shared across: - `admin_restore` - `restore_saved_board_backup` - `board_restore` --- #### `db/logging.rs` (moved from `logging.rs`) - **#33 – Log rotation** - Replaced `rolling::never` with `rolling::daily` - Logs now rotate automatically: ``` rustchan.log.YYYY-MM-DD ``` - **Location + API updates** - Moved file to: `db/logging.rs` - Renamed `log_dir` → `db_dir` - Logs now stored alongside `chan.db` - Updated documentation accordingly --- ### Required Follow-ups - Update module declaration: ```rust // db/mod.rs pub mod logging;
1 parent 586d378 commit 9e728fa

6 files changed

Lines changed: 178 additions & 96 deletions

File tree

audit_report.md

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,6 @@
2424

2525
| # | Severity | Title | File |
2626
|---|----------|-------|------|
27-
| 1 | **Critical** | SVG served inline — stored XSS | `handlers/board.rs:753` |
28-
| 2 | **Critical** | ChanNet Ed25519 signatures not verified | `chan_net/import.rs:~90` |
29-
| 3 | **Critical** | ffmpeg process not killed on timeout | `workers/mod.rs:460` |
30-
| 4 | **High** | Swapped format args in mod log entry | `handlers/admin/moderation.rs:183` |
31-
| 5 | **High** | Unbounded body on admin restore endpoints | `server/server.rs:598,606` |
32-
| 6 | **High** | `edit_post` misuses `execute_batch` for transactions | `db/posts.rs:~190` |
33-
| 7 | **High** | `insert_board_if_absent` uses `last_insert_rowid()` | `db/chan_net.rs:~57` |
34-
| 8 | **High** | Catalog has no ETag caching | `handlers/board.rs:500` |
35-
| 9 | **High** | ChanNet `/chan/refresh` and `/chan/poll` unauthenticated | `chan_net/mod.rs:146` |
36-
| 10 | **High** | Worker `JoinHandle`s discarded; shutdown is blind sleep | `server/server.rs:239` |
37-
| 11 | **High** | `get_per_board_stats` still uses correlated subqueries | `db/boards.rs:416` |
3827
| 12 | **Medium** | PoW nonce prune not rate-gated under concurrency | `utils/crypto.rs:~165` |
3928
| 13 | **Medium** | `constant_time_eq` in posts.rs — use `subtle` crate | `db/posts.rs:~270` |
4029
| 14 | **Medium** | `update_settings_file_site_names` matches comment lines | `config.rs:~320` |

clippy_reports/clippy_raw.txt

Whitespace-only changes.

clippy_reports/summary.txt

Lines changed: 0 additions & 5 deletions
This file was deleted.

src/db/posts.rs

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use crate::models::Post;
3333
use anyhow::{Context, Result};
3434
use rusqlite::{params, OptionalExtension};
35+
use subtle::ConstantTimeEq as _;
3536

3637
// ─── Retry budget constant ────────────────────────────────────────────────────
3738

@@ -351,8 +352,16 @@ pub fn verify_deletion_token(
351352

352353
/// Edit a post's body, verified against the deletion token and a per-board edit window.
353354
///
354-
/// `edit_window_secs` comes from the board (0 means use the default 300s window).
355-
/// The caller is responsible for checking `board.allow_editing` before calling this.
355+
/// `edit_window_secs` semantics (FIX[#25]):
356+
/// - `> 0` — use this many seconds as the edit window.
357+
/// - `== 0` — no time limit; editing is always allowed regardless of post age.
358+
/// Use this when the board's `edit_window_secs` column is 0.
359+
/// - `< 0` — use the built-in default of 300 s.
360+
///
361+
/// Previously, both `0` and negative values mapped to 300 s, making it
362+
/// impossible for a board to configure "no time limit". The caller is
363+
/// responsible for checking `board.allow_editing` before calling this.
364+
///
356365
/// Returns `Ok(true)` on success, `Ok(false)` if the token is wrong or the
357366
/// edit window has closed; `Err` for database failures.
358367
///
@@ -376,10 +385,11 @@ pub fn edit_post(
376385
new_body_html: &str,
377386
edit_window_secs: i64,
378387
) -> Result<bool> {
379-
let window = if edit_window_secs <= 0 {
380-
300
381-
} else {
382-
edit_window_secs
388+
// FIX[#25]: `0` now means "no time limit". Negative values use the 300 s default.
389+
let window_opt: Option<i64> = match edit_window_secs.cmp(&0) {
390+
std::cmp::Ordering::Greater => Some(edit_window_secs),
391+
std::cmp::Ordering::Equal => None, // no limit — skip the window check entirely
392+
std::cmp::Ordering::Less => Some(300), // negative → built-in default
383393
};
384394

385395
// FIX[High-6]: Use rusqlite's typed transaction API instead of raw
@@ -413,9 +423,12 @@ pub fn edit_post(
413423
}
414424

415425
let now = chrono::Utc::now().timestamp();
416-
if now.saturating_sub(created_at) > window {
417-
return Ok(false);
426+
if let Some(window) = window_opt {
427+
if now.saturating_sub(created_at) > window {
428+
return Ok(false);
429+
}
418430
}
431+
// window_opt == None → no time limit, skip the check
419432

420433
tx.execute(
421434
"UPDATE posts SET body = ?1, body_html = ?2, edited_at = ?3 WHERE id = ?4",
@@ -431,20 +444,14 @@ pub fn edit_post(
431444

432445
/// Constant-time byte slice comparison to prevent timing side-channel attacks.
433446
///
434-
/// FIX[MED-7]: The previous implementation returned false immediately when
435-
/// lengths differed, leaking token length as a timing signal. The comparison
436-
/// now processes all bytes from the longer slice regardless of length, folding
437-
/// the length mismatch into the accumulator.
447+
/// FIX[#13]: Replaced hand-rolled implementation with the `subtle` crate's
448+
/// `ConstantTimeEq`. The previous hand-rolled version was correct, but using an
449+
/// audited crate is preferable to maintaining our own. Note: `subtle`'s
450+
/// implementation for `[u8]` short-circuits on a length mismatch (leaking
451+
/// length), but deletion tokens are always 32-char hex strings so lengths will
452+
/// never differ in practice.
438453
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
439-
let max_len = a.len().max(b.len());
440-
// Non-zero when lengths differ.
441-
let mut diff = u8::try_from(a.len() ^ b.len()).unwrap_or(u8::MAX);
442-
for i in 0..max_len {
443-
let x = a.get(i).copied().unwrap_or(0);
444-
let y = b.get(i).copied().unwrap_or(0);
445-
diff |= x ^ y;
446-
}
447-
diff == 0
454+
a.ct_eq(b).into()
448455
}
449456

450457
// ─── LIKE escape helper ───────────────────────────────────────────────────────
@@ -686,9 +693,17 @@ pub fn get_poll_for_thread(
686693
/// the same INSERT statement via a correlated WHERE EXISTS. A mismatched
687694
/// (`poll_id`, `option_id`) pair inserts nothing and returns false.
688695
///
689-
/// Note (MED-14): This function returns false for two distinct cases:
696+
/// FIX[#16]: Poll expiry is now re-checked atomically inside the INSERT's
697+
/// WHERE clause. Previously, expiry was only verified by the handler before
698+
/// calling this function. A poll expiring between the handler's check and the
699+
/// INSERT would allow a vote on an expired poll. The `expires_at` guard here
700+
/// closes that race: the INSERT is a no-op if the poll has expired by the time
701+
/// `SQLite` evaluates it.
702+
///
703+
/// Note (MED-14): This function returns false for three distinct cases:
690704
/// 1. The voter has already voted (UNIQUE constraint fires INSERT OR IGNORE)
691705
/// 2. The `option_id` does not belong to `poll_id` (EXISTS check fails)
706+
/// 3. The poll has expired (`expires_at` guard in EXISTS fails)
692707
///
693708
/// Callers that need to distinguish these cases should call `cast_vote` and, on
694709
/// false, separately query whether the IP has voted on this poll. A future
@@ -706,8 +721,11 @@ pub fn cast_vote(
706721
"INSERT OR IGNORE INTO poll_votes (poll_id, option_id, ip_hash)
707722
SELECT ?1, ?2, ?3
708723
WHERE EXISTS (
709-
SELECT 1 FROM poll_options
710-
WHERE id = ?2 AND poll_id = ?1
724+
SELECT 1 FROM poll_options po
725+
JOIN polls p ON p.id = po.poll_id
726+
WHERE po.id = ?2
727+
AND po.poll_id = ?1
728+
AND (p.expires_at IS NULL OR p.expires_at > unixepoch())
711729
)",
712730
params![poll_id, option_id, ip_hash],
713731
)?;

src/handlers/admin/backup.rs

Lines changed: 103 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,56 @@ use tokio::io::AsyncWriteExt as _;
3030
use tokio_util::io::ReaderStream;
3131
use tracing::{info, warn};
3232

33+
// ─── URL query-string encoder ─────────────────────────────────────────────────
34+
//
35+
// FIX[#30]: `encode_q` was previously defined as an inner function inside two
36+
// separate async handlers, with one implementation slightly less efficient than
37+
// the other. Extracted here so both call sites share a single definition.
38+
//
39+
// Encodes `s` using percent-encoding, with spaces as `+` (form-URL encoding).
40+
// Used only for error messages in redirect query strings — not for URL paths.
41+
fn encode_q(s: &str) -> String {
42+
const fn nibble(n: u8) -> char {
43+
match n {
44+
0 => '0',
45+
1 => '1',
46+
2 => '2',
47+
3 => '3',
48+
4 => '4',
49+
5 => '5',
50+
6 => '6',
51+
7 => '7',
52+
8 => '8',
53+
9 => '9',
54+
10 => 'A',
55+
11 => 'B',
56+
12 => 'C',
57+
13 => 'D',
58+
14 => 'E',
59+
_ => 'F',
60+
}
61+
}
62+
let mut out = String::with_capacity(s.len());
63+
for b in s.bytes() {
64+
match b {
65+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
66+
out.push(b as char);
67+
}
68+
b' ' => out.push('+'),
69+
b => {
70+
out.push('%');
71+
out.push(nibble(b >> 4));
72+
out.push(nibble(b & 0xf));
73+
}
74+
}
75+
}
76+
out
77+
}
78+
79+
// FIX[#19]: Module-level constant so it can be referenced inside closures and
80+
// loops without triggering the "item after statements" clippy lint.
81+
const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0";
82+
3383
#[allow(clippy::too_many_lines)]
3484
pub async fn admin_backup(State(state): State<AppState>, jar: CookieJar) -> Result<Response> {
3585
let session_id = jar
@@ -397,6 +447,35 @@ pub async fn admin_restore(
397447
.map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp DB: {e}")))?;
398448
copy_limited(&mut entry, &mut out, ZIP_ENTRY_MAX_BYTES)
399449
.map_err(|e| AppError::Internal(anyhow::anyhow!("Write temp DB: {e}")))?;
450+
451+
// FIX[#19]: Validate SQLite magic bytes before handing this
452+
// file to the backup API. Without this check a malicious or
453+
// corrupted "chan.db" inside the zip (e.g. a shell script or
454+
// truncated file) would be opened by rusqlite, which could
455+
// panic or return opaque internal errors. The magic sequence
456+
// is the first 16 bytes of every valid SQLite 3 database.
457+
let mut header = [0u8; 16];
458+
{
459+
use std::io::Read;
460+
let mut f = std::fs::File::open(&temp_db).map_err(|e| {
461+
AppError::Internal(anyhow::anyhow!("Magic check open: {e}"))
462+
})?;
463+
if f.read_exact(&mut header).is_err() {
464+
let _ = std::fs::remove_file(&temp_db);
465+
return Err(AppError::BadRequest(
466+
"Uploaded chan.db is not a valid SQLite database (file too small)."
467+
.into(),
468+
));
469+
}
470+
}
471+
if &header != SQLITE_HEADER {
472+
let _ = std::fs::remove_file(&temp_db);
473+
return Err(AppError::BadRequest(
474+
"Uploaded chan.db is not a valid SQLite database (invalid magic bytes)."
475+
.into(),
476+
));
477+
}
478+
400479
db_extracted = true;
401480

402481
} else if let Some(rel) = name.strip_prefix("uploads/") {
@@ -1430,6 +1509,30 @@ pub async fn restore_saved_full_backup(
14301509
.map_err(|e| AppError::Internal(anyhow::anyhow!("Create temp DB: {e}")))?;
14311510
copy_limited(&mut entry, &mut out, ZIP_ENTRY_MAX_BYTES)
14321511
.map_err(|e| AppError::Internal(anyhow::anyhow!("Write temp DB: {e}")))?;
1512+
1513+
// FIX[#19]: Validate SQLite magic bytes (same guard as admin_restore).
1514+
let mut header = [0u8; 16];
1515+
{
1516+
use std::io::Read;
1517+
let mut f = std::fs::File::open(&temp_db).map_err(|e| {
1518+
AppError::Internal(anyhow::anyhow!("Magic check open: {e}"))
1519+
})?;
1520+
if f.read_exact(&mut header).is_err() {
1521+
let _ = std::fs::remove_file(&temp_db);
1522+
return Err(AppError::BadRequest(
1523+
"Uploaded chan.db is not a valid SQLite database (file too small)."
1524+
.into(),
1525+
));
1526+
}
1527+
}
1528+
if &header != SQLITE_HEADER {
1529+
let _ = std::fs::remove_file(&temp_db);
1530+
return Err(AppError::BadRequest(
1531+
"Uploaded chan.db is not a valid SQLite database (invalid magic bytes)."
1532+
.into(),
1533+
));
1534+
}
1535+
14331536
db_extracted = true;
14341537
} else if let Some(rel) = name.strip_prefix("uploads/") {
14351538
if rel.is_empty() {
@@ -1519,24 +1622,6 @@ pub async fn restore_saved_board_backup(
15191622
jar: CookieJar,
15201623
Form(form): Form<RestoreSavedForm>,
15211624
) -> Result<Response> {
1522-
fn encode_q(s: &str) -> String {
1523-
#[allow(clippy::arithmetic_side_effects)]
1524-
const fn nibble(n: u8) -> char {
1525-
match n {
1526-
0..=9 => (b'0' + n) as char,
1527-
_ => (b'A' + n - 10) as char,
1528-
}
1529-
}
1530-
s.bytes()
1531-
.flat_map(|b| match b {
1532-
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1533-
vec![b as char]
1534-
}
1535-
b' ' => vec!['+'],
1536-
b => vec!['%', nibble(b >> 4), nibble(b & 0xf)],
1537-
})
1538-
.collect()
1539-
}
15401625
let session_id = jar
15411626
.get(super::SESSION_COOKIE)
15421627
.map(|c| c.value().to_string());
@@ -2250,31 +2335,6 @@ pub async fn board_restore(
22502335
jar: CookieJar,
22512336
mut multipart: Multipart,
22522337
) -> Response {
2253-
#[allow(clippy::arithmetic_side_effects)]
2254-
const fn nibble(n: u8) -> char {
2255-
match n {
2256-
0..=9 => (b'0' + n) as char,
2257-
_ => (b'A' + n - 10) as char,
2258-
}
2259-
}
2260-
fn encode_q(s: &str) -> String {
2261-
let mut out = String::with_capacity(s.len());
2262-
for b in s.bytes() {
2263-
match b {
2264-
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2265-
out.push(b as char);
2266-
}
2267-
b' ' => out.push('+'),
2268-
b => {
2269-
out.push('%');
2270-
out.push(nibble(b >> 4));
2271-
out.push(nibble(b & 0xf));
2272-
}
2273-
}
2274-
}
2275-
out
2276-
}
2277-
22782338
// Run the whole operation as a fallible async block so any early return
22792339
// with Err(...) is caught below and turned into a redirect.
22802340
let result: Result<String> = async {

src/logging.rs

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,56 @@
1-
// logging.rs — Structured logging initialisation.
1+
// db/logging.rs — Structured logging initialisation.
22
//
33
// Sets up two output layers:
44
// 1. stderr — human-readable, `info` and above (terminal/systemd journal)
55
// 2. file — JSON formatted, `debug` and above, written to
6-
// `rustchan.log` inside `log_dir`
6+
// `rustchan.log` inside `db_dir`
77
//
88
// Respects `RUST_LOG` env var if set.
9+
//
10+
// NOTE: this module lives in `db/` so that log files are written alongside the
11+
// database file rather than next to the binary. The caller should pass the
12+
// same directory used for `chan.db` (typically the directory containing the
13+
// binary at first run, but overridable via config).
914

1015
use std::path::Path;
1116
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
1217

1318
/// Initialise the global tracing subscriber.
1419
///
15-
/// `log_dir` is the directory where `rustchan.log` will be written.
16-
/// Typically this is the directory that contains the binary itself
17-
/// (`std::env::current_exe()?.parent()`), so log output stays alongside
18-
/// the executable and is easy to find.
20+
/// `db_dir` is the directory where `rustchan.log` will be written.
21+
/// This should be the same directory as `chan.db` so that logs and the
22+
/// database stay together and are easy to locate, back up, and rotate as
23+
/// a unit.
24+
///
25+
/// FIX[#33]: The file appender now uses daily rotation
26+
/// (`tracing_appender::rolling::daily`) instead of `rolling::never`. A single
27+
/// append-only log file grows without bound on a busy instance; if the
28+
/// filesystem fills, `SQLite`'s WAL checkpoint will fail and can corrupt the
29+
/// database. Daily rotation caps each log file to roughly one day of output
30+
/// and leaves old files on disk with a date suffix so they can be pruned by a
31+
/// standard `logrotate` rule or a cron job.
1932
///
20-
/// The file appender is non-rotating — a single `rustchan.log` is used for
21-
/// the lifetime of the process. Rotation can be added later via
22-
/// `tracing_appender::rolling::daily` / `hourly` without changing callers.
23-
pub fn init_logging(log_dir: &Path) {
33+
/// Suggested logrotate stanza (`/etc/logrotate.d/rustchan`):
34+
/// ```text
35+
/// /path/to/db/rustchan.log.* {
36+
/// daily
37+
/// rotate 14
38+
/// compress
39+
/// missingok
40+
/// notifempty
41+
/// copytruncate
42+
/// }
43+
/// ```
44+
pub fn init_logging(db_dir: &Path) {
2445
let env_filter = EnvFilter::try_from_default_env()
2546
.unwrap_or_else(|_| EnvFilter::new("rustchan=info,tower_http=warn"));
2647

2748
// stderr layer — compact human-readable output for the terminal.
2849
let stderr_layer = fmt::layer().with_target(false).compact();
2950

3051
// File layer — JSON, includes file/line for structured log analysis.
31-
// Uses `tracing_appender::rolling::never` so the file is never rotated
32-
// automatically; restart the process to start a fresh log.
33-
let file_appender = tracing_appender::rolling::never(log_dir, "rustchan.log");
52+
// FIX[#33]: daily rotation; files are named `rustchan.log.YYYY-MM-DD`.
53+
let file_appender = tracing_appender::rolling::daily(db_dir, "rustchan.log");
3454
let file_layer = fmt::layer()
3555
.json()
3656
.with_writer(file_appender)

0 commit comments

Comments
 (0)