@@ -30,6 +30,56 @@ use tokio::io::AsyncWriteExt as _;
3030use tokio_util:: io:: ReaderStream ;
3131use 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) ]
3484pub 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 {
0 commit comments