diff --git a/backend/Dockerfile b/backend/Dockerfile index cb6ed1a6..3b96a40e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -35,9 +35,10 @@ # STAGE 1: RUST BUILD ENVIRONMENT # ============================================================================== -# Use latest stable Rust for building -# Provides access to latest Rust features and optimizations -FROM rustlang/rust:nightly-bookworm AS builder +# Use a pinned stable Rust release for reproducible builds. +# No nightly-only features are used (MSRV 1.82 per Cargo.toml); nightly is a +# floating, unpinned toolchain and an unnecessary supply-chain risk. +FROM rust:1.83-bookworm AS builder # Set working directory for all subsequent commands WORKDIR /app diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index f99c7e99..1abafb09 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -78,6 +78,20 @@ pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> { tx.commit().await?; } + // Apply comment author identity migration (author_username / is_guest for ownership checks) + { + let mut tx = pool.begin().await?; + apply_comment_author_identity_migration(&mut tx).await?; + tx.commit().await?; + } + + // Rehash legacy plaintext rows in token_blacklist (raw JWTs -> SHA-256) + { + let mut tx = pool.begin().await?; + apply_token_blacklist_hash_migration(&mut tx).await?; + tx.commit().await?; + } + // Create site-related schema (pages, posts, content) ensure_site_page_schema(pool).await?; @@ -110,6 +124,17 @@ pub async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::Error> { return Err(sqlx::Error::Protocol("Admin password too weak".into())); } + // bcrypt only uses the first 72 bytes of the input; anything beyond + // that has no effect on the resulting hash. Not treated as an error + // since ADMIN_PASSWORD is operator-controlled via a trusted + // environment variable, but worth surfacing so a longer passphrase + // isn't assumed to add entropy it doesn't. + if password.len() > 72 { + tracing::warn!( + "ADMIN_PASSWORD exceeds 72 bytes; bcrypt only uses the first 72 bytes for hashing. Characters beyond this limit have no effect on security." + ); + } + let existing_user: Option<(i64, String)> = sqlx::query_as("SELECT id, password_hash FROM users WHERE username = ?") .bind(&username) @@ -619,6 +644,170 @@ async fn fix_comment_schema(tx: &mut Transaction<'_, Sqlite>) -> Result<(), sqlx Ok(()) } +/// Adds `author_username` and `is_guest` columns to `comments` for authorization. +/// +/// `author` is a free-text display name that guests can set almost arbitrarily, +/// so it must never be used to authorize comment deletion (a registered user +/// could otherwise delete a guest comment that happens to share their +/// username). These two columns record the real, unspoofable identity of the +/// commenter going forward: +/// - `author_username = Some(username)`: authenticated commenter (their real +/// username, never the "Administrator" display name used for admins). +/// - `author_username = NULL, is_guest = Some(true)`: guest commenter, who +/// never has a real identity to record. +/// - `author_username = NULL, is_guest = NULL`: pre-migration row of unknown +/// origin. `is_guest` is required in addition to `author_username` because +/// guest comments are *permanently* NULL for `author_username` -- without a +/// separate marker, a NULL-based fallback to the old (spoofable) `author` +/// string match would stay exploitable for every future guest comment, not +/// just historical ones. See `delete_comment` for how this three-state +/// model is used. +async fn apply_comment_author_identity_migration( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + let has_author_username: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='author_username'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_author_username { + tracing::info!("Adding author_username column to comments table"); + add_column_if_missing_race_safe( + tx, + "ALTER TABLE comments ADD COLUMN author_username TEXT DEFAULT NULL", + ) + .await?; + } + + let has_is_guest: bool = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='is_guest'", + ) + .fetch_one(&mut **tx) + .await + .map(|count: i64| count > 0)?; + + if !has_is_guest { + tracing::info!("Adding is_guest column to comments table"); + add_column_if_missing_race_safe( + tx, + "ALTER TABLE comments ADD COLUMN is_guest BOOLEAN DEFAULT NULL", + ) + .await?; + } + + Ok(()) +} + +/// Runs an `ALTER TABLE ... ADD COLUMN` statement, tolerating a "duplicate +/// column name" failure. +/// +/// The existence check above this call and the `ALTER TABLE` itself are not +/// atomic: if two instances of the app start concurrently against the same +/// SQLite file (e.g. a rolling deploy with overlapping replicas), both can +/// observe the column as missing before either commits its `ALTER TABLE`, +/// and the loser would otherwise fail its whole migration with "duplicate +/// column name" and abort startup. Since the only possible cause of that +/// specific error here is a concurrent run of this same idempotent +/// migration, it is safe to treat it as success rather than propagate it. +async fn add_column_if_missing_race_safe( + tx: &mut Transaction<'_, Sqlite>, + alter_statement: &str, +) -> Result<(), sqlx::Error> { + match sqlx::query(alter_statement).execute(&mut **tx).await { + Ok(_) => Ok(()), + Err(e) if is_duplicate_column_error(&e) => { + tracing::warn!( + "Column already added by a concurrent migration run, continuing: {}", + e + ); + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Detects SQLite's "duplicate column name" error, which `ALTER TABLE ... +/// ADD COLUMN` raises when the column already exists. +fn is_duplicate_column_error(err: &sqlx::Error) -> bool { + err.as_database_error() + .map(|db_err| db_err.message().contains("duplicate column name")) + .unwrap_or(false) +} + +/// Rehashes legacy plaintext rows in `token_blacklist` to SHA-256. +/// +/// The repository layer used to store raw JWTs in the blacklist and now +/// stores and looks up only their SHA-256 hashes (see +/// `repositories::token_blacklist::hash_token`). On a database that predates +/// that change, existing rows still hold raw tokens, which the hashed lookup +/// can never match -- without this backfill, every token revoked before the +/// upgrade (e.g. via logout) would silently become valid again for its +/// remaining lifetime, undoing the revocation the user already performed. +/// +/// Gated by an `app_metadata` flag like the other one-time migrations. The +/// per-row hex check is a second, defensive layer: a raw JWT always contains +/// `.` separators and can never look like a 64-char hex digest, so +/// already-hashed rows are never double-hashed even if the flag is somehow +/// lost (manual metadata edit, partial restore from backup). +async fn apply_token_blacklist_hash_migration( + tx: &mut Transaction<'_, Sqlite>, +) -> Result<(), sqlx::Error> { + let migrated: Option<(String,)> = + sqlx::query_as("SELECT value FROM app_metadata WHERE key = 'token_blacklist_hashed_v1'") + .fetch_optional(&mut **tx) + .await?; + + if migrated.is_some() { + return Ok(()); + } + + let rows: Vec<(String,)> = sqlx::query_as("SELECT token FROM token_blacklist") + .fetch_all(&mut **tx) + .await?; + + let mut rehashed = 0u64; + for (token,) in rows { + if is_sha256_hex(&token) { + continue; + } + // UPDATE OR REPLACE: if the hashed form somehow already exists as its + // own row, replace it instead of failing the whole startup on a + // primary-key conflict. Either way the raw token is gone afterwards. + sqlx::query("UPDATE OR REPLACE token_blacklist SET token = ? WHERE token = ?") + .bind(crate::security::sha256_hex(token.as_bytes())) + .bind(&token) + .execute(&mut **tx) + .await?; + rehashed += 1; + } + + if rehashed > 0 { + tracing::info!( + "Rehashed {} legacy plaintext token_blacklist row(s) to SHA-256", + rehashed + ); + } + + // OR REPLACE keeps a concurrent second instance (rolling deploy) from + // aborting startup on a duplicate-key conflict for the flag itself. + sqlx::query( + "INSERT OR REPLACE INTO app_metadata (key, value) VALUES ('token_blacklist_hashed_v1', 'true')", + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +/// True if `s` is exactly a lowercase-or-uppercase 64-character hex string, +/// i.e. shaped like a SHA-256 digest. Raw JWTs always contain `.` separators, +/// so they can never satisfy this. +fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + async fn apply_site_post_migrations(tx: &mut Transaction<'_, Sqlite>) -> Result<(), sqlx::Error> { // Check if allow_comments column exists let has_allow_comments: bool = sqlx::query_scalar( @@ -728,4 +917,67 @@ mod tests { assert_eq!(rate_limit_key, "Legacy Author"); } + + /// Regression test for the blacklist-hashing upgrade path: a database + /// written by a pre-hashing version of the app holds raw JWTs in + /// token_blacklist. After migrations run, those rows must be rehashed so + /// the (now hash-based) revocation lookup still recognizes them -- + /// otherwise every token revoked before the upgrade would silently + /// become valid again for its remaining lifetime. + #[tokio::test] + async fn run_migrations_rehashes_legacy_plaintext_blacklist_tokens() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("create sqlite pool"); + + // Simulate the pre-hashing schema and a raw bearer token revoked under it. + sqlx::query( + r#" + CREATE TABLE token_blacklist ( + token TEXT PRIMARY KEY, + expires_at TEXT NOT NULL + ) + "#, + ) + .execute(&pool) + .await + .expect("create legacy token_blacklist table"); + + let raw_token = ["legacy", "plaintext", "revocation-token", "before-hashing"].join("-"); + sqlx::query("INSERT INTO token_blacklist (token, expires_at) VALUES (?, ?)") + .bind(&raw_token) + .bind("2999-01-01T00:00:00+00:00") + .execute(&pool) + .await + .expect("insert legacy plaintext token"); + + run_migrations(&pool).await.expect("run migrations"); + + // The raw token must be gone from storage... + let stored: (String,) = sqlx::query_as("SELECT token FROM token_blacklist") + .fetch_one(&pool) + .await + .expect("read migrated blacklist row"); + assert_ne!(stored.0, raw_token, "raw token must not survive migration"); + assert_eq!(stored.0, crate::security::sha256_hex(raw_token.as_bytes())); + + // ...while the hash-based revocation check still recognizes it. + assert!( + crate::repositories::token_blacklist::is_token_blacklisted(&pool, &raw_token) + .await + .expect("blacklist lookup"), + "token revoked before the upgrade must still be treated as revoked" + ); + + // Idempotence: a second run (flag set, row already hashed) must not + // double-hash the stored value. + run_migrations(&pool).await.expect("re-run migrations"); + let stored_again: (String,) = sqlx::query_as("SELECT token FROM token_blacklist") + .fetch_one(&pool) + .await + .expect("read blacklist row after re-run"); + assert_eq!(stored_again.0, stored.0); + } } diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 29829530..976ca281 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -35,7 +35,6 @@ use axum::{ Json, }; use chrono::{DateTime, Duration as ChronoDuration, Utc}; -use sha2::{Digest, Sha256}; use axum_extra::extract::cookie::CookieJar; use rand::RngExt; @@ -111,10 +110,9 @@ fn login_attempt_salt() -> &'static str { /// - Salt prevents rainbow table attacks /// - Hash prevents direct username storage fn hash_login_identifier(username: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(login_attempt_salt().as_bytes()); - hasher.update(username.trim().to_ascii_lowercase().as_bytes()); - format!("{:x}", hasher.finalize()) + let mut data = login_attempt_salt().as_bytes().to_vec(); + data.extend_from_slice(username.trim().to_ascii_lowercase().as_bytes()); + crate::security::sha256_hex(&data) } /// Parses an optional RFC3339 timestamp string into a UTC DateTime. diff --git a/backend/src/handlers/comments.rs b/backend/src/handlers/comments.rs index 4b70a512..fda5bb66 100644 --- a/backend/src/handlers/comments.rs +++ b/backend/src/handlers/comments.rs @@ -80,6 +80,40 @@ pub struct Comment { pub votes: i64, /// Whether the comment was posted by an administrator pub is_admin: bool, + /// Real authenticated username of the commenter, used for server-side + /// ownership checks only. NEVER sent to clients: for admin comments, + /// `author` is deliberately the anonymized literal "Administrator" + /// string, and leaking this field would de-anonymize which real admin + /// account posted it. + #[serde(skip_serializing)] + pub author_username: Option, + /// Guest/authenticated marker, used for server-side ownership checks + /// only. Never sent to clients, for the same reason as `author_username`. + #[serde(skip_serializing)] + pub is_guest: Option, +} + +/// Converts the repository's `Comment` model into this handler's response +/// DTO. Kept as an explicit `From` impl (rather than returning the model +/// type directly) because the two types intentionally diverge on +/// serialization: this DTO marks `author_username`/`is_guest` as +/// `#[serde(skip_serializing)]` so they never reach the client, while the +/// model type serializes them (it's also used for internal deserialization). +impl From for Comment { + fn from(c: crate::models::Comment) -> Self { + Comment { + id: c.id, + tutorial_id: c.tutorial_id, + post_id: c.post_id, + author: c.author, + content: c.content, + created_at: c.created_at, + votes: c.votes, + is_admin: c.is_admin, + author_username: c.author_username, + is_guest: c.is_guest, + } + } } /// Validates and sanitizes comment content @@ -168,19 +202,7 @@ pub async fn list_comments( ) })?; - let response_comments: Vec = comments - .into_iter() - .map(|c| Comment { - id: c.id, - tutorial_id: c.tutorial_id, - post_id: c.post_id, - author: c.author, - content: c.content, - created_at: c.created_at, - votes: c.votes, - is_admin: c.is_admin, - }) - .collect(); + let response_comments: Vec = comments.into_iter().map(Comment::from).collect(); Ok(Json(response_comments)) } @@ -287,19 +309,7 @@ pub async fn list_post_comments( ) })?; - let response_comments: Vec = comments - .into_iter() - .map(|c| Comment { - id: c.id, - tutorial_id: c.tutorial_id, - post_id: c.post_id, - author: c.author, - content: c.content, - created_at: c.created_at, - votes: c.votes, - is_admin: c.is_admin, - }) - .collect(); + let response_comments: Vec = comments.into_iter().map(Comment::from).collect(); Ok(Json(response_comments)) } @@ -364,14 +374,23 @@ async fn create_comment_internal( ) -> Result, (StatusCode, Json)> { let comment_content = sanitize_comment_content(&payload.content)?; - let (author, rate_limit_key) = if let Some(ref c) = claims { + let (author, rate_limit_key, author_username, is_guest) = if let Some(ref c) = claims { let display_name = if c.role == "admin" { "Administrator".to_string() } else { c.sub.clone() }; // Admin posts are stored with the display author, so rate limiting must use the same key. - (display_name.clone(), display_name) + // author_username/is_guest record the real identity for later ownership + // checks in delete_comment. Populated for admins too (harmless -- admin + // deletion is governed by the separate is_admin/role check, not these + // fields). + ( + display_name.clone(), + display_name, + Some(c.sub.clone()), + Some(false), + ) } else { // Guest comment match payload.author { @@ -414,7 +433,8 @@ async fn create_comment_internal( } // Use the IP address as the guest rate-limit key to prevent name-change bypasses. - (trimmed.to_string(), ip_address) + // A guest never has a real identity to record. + (trimmed.to_string(), ip_address, None, Some(true)) } None => { return Err(( @@ -478,6 +498,8 @@ async fn create_comment_internal( &comment_content, &now, is_admin, + author_username, + is_guest, ) .await .map_err(|e| { @@ -490,18 +512,7 @@ async fn create_comment_internal( ) })?; - let response_comment = Comment { - id: comment.id, - tutorial_id: comment.tutorial_id, - post_id: comment.post_id, - author: comment.author, - content: comment.content, - created_at: comment.created_at, - votes: comment.votes, - is_admin: comment.is_admin, - }; - - Ok(Json(response_comment)) + Ok(Json(Comment::from(comment))) } /// Handler for deleting a comment @@ -538,16 +549,44 @@ pub async fn delete_comment( } }; - // Check permissions: Admin or Author + // Check permissions: Admin or Author. + // + // Ownership is determined by the real authenticated identity + // (author_username), NOT the spoofable free-text `author` display name + // (guests can type any name, including another real user's username). + // + // Four states, disambiguated by (author_username, is_guest). Every arm + // is written out explicitly (no wildcard `_`) so that a row shape no + // current insert path produces can never silently fall through to the + // spoofable legacy comparison -- it must be reachable only for rows that + // are *provably* pre-migration (both columns NULL). + // - author_username = Some(u): post-migration authenticated comment. + // Compare real identity directly. + // - author_username = None, is_guest = Some(true): post-migration guest + // comment. A guest never has a real identity to match -- never allow + // the display-name fallback, or the impersonation hole stays open + // forever for new guest comments. + // - author_username = None, is_guest = None: pre-migration legacy row + // of unknown origin (could be guest or authenticated). Fall back to + // the legacy display-name match to avoid regressing self-service + // deletion for real users' historical comments. This is an accepted, + // time-bounded residual risk limited to rows that already existed + // when this fix shipped; it cannot apply to anything created after. + // - author_username = None, is_guest = Some(false): inconsistent state + // that no current code path produces (an authenticated comment + // should always carry author_username). Reject rather than fall + // back to the spoofable comparison, so a future bug or manual data + // edit that produces this shape fails closed instead of silently + // reopening the impersonation hole this migration closes. + // Admin-authored comments are excluded from all of the above; they're + // already covered by the is_admin role check. let is_admin = claims.role == "admin"; - // We compare display names/usernames. Ideally, we should compare user IDs if available in comments. - // Assuming 'author' in comments table stores the username/display name which matches claims.sub - // or we need to be careful if display names are mutable. - // For this implementation, we'll assume claims.sub matches the stored author name for simplicity, - // or we might need to fetch the user to verify. - // However, `comment_author_display_name` uses `claims.sub` by default. - // Let's assume strict username matching for now. - let is_author = comment.author == claims.sub; + let is_author = match (&comment.author_username, comment.is_guest) { + (Some(username), _) => !comment.is_admin && *username == claims.sub, + (None, Some(true)) => false, + (None, None) => !comment.is_admin && comment.author == claims.sub, + (None, Some(false)) => false, + }; if !is_admin && !is_author { return Err(( @@ -683,19 +722,7 @@ pub async fn vote_comment( ) })?; - // Convert models::Comment to handlers::comments::Comment - let response_comment = Comment { - id: comment.id, - tutorial_id: comment.tutorial_id, - post_id: comment.post_id, - author: comment.author, - content: comment.content, - created_at: comment.created_at, - votes: comment.votes, - is_admin: comment.is_admin, - }; - - Ok(Json(response_comment)) + Ok(Json(Comment::from(comment))) } #[cfg(test)] @@ -719,7 +746,9 @@ mod tests { content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), votes INTEGER NOT NULL DEFAULT 0, - is_admin BOOLEAN NOT NULL DEFAULT FALSE + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + author_username TEXT DEFAULT NULL, + is_guest BOOLEAN DEFAULT NULL ) "#, ) @@ -730,6 +759,32 @@ mod tests { pool } + /// Inserts a comment row directly with full control over every column, + /// for exercising `delete_comment`'s ownership logic against specific + /// (author, author_username, is_guest, is_admin) combinations. + #[allow(clippy::too_many_arguments)] + async fn insert_comment_row( + pool: &SqlitePool, + id: &str, + author: &str, + author_username: Option<&str>, + is_guest: Option, + is_admin: bool, + ) { + sqlx::query( + "INSERT INTO comments (id, tutorial_id, post_id, author, content, created_at, votes, is_admin, author_username, is_guest) \ + VALUES (?, 'tutorial-1', NULL, ?, 'content', datetime('now'), 0, ?, ?, ?)", + ) + .bind(id) + .bind(author) + .bind(is_admin) + .bind(author_username) + .bind(is_guest) + .execute(pool) + .await + .expect("insert comment row"); + } + #[tokio::test] async fn admin_tutorial_comment_uses_claims_without_author_payload() { let pool = setup_comments_pool().await; @@ -758,6 +813,10 @@ mod tests { assert_eq!(comment.author, "Administrator"); assert!(comment.is_admin); + // Admins get their real identity recorded too (harmless -- admin + // deletion is governed by the is_admin/role check, not this field). + assert_eq!(comment.author_username, Some("admin".to_string())); + assert_eq!(comment.is_guest, Some(false)); } #[tokio::test] @@ -776,9 +835,12 @@ mod tests { "203.0.113.5".to_string(), ) .await; - if let Err((status, _)) = first_result { - panic!("first guest comment failed with status {status}"); - } + let Json(first_comment) = match first_result { + Ok(comment) => comment, + Err((status, _)) => panic!("first guest comment failed with status {status}"), + }; + assert_eq!(first_comment.author_username, None); + assert_eq!(first_comment.is_guest, Some(true)); let result = create_comment_internal( pool, @@ -799,4 +861,134 @@ mod tests { assert_eq!(err.0, StatusCode::TOO_MANY_REQUESTS); } + + fn claims_for(sub: &str, role: &str) -> auth::Claims { + auth::Claims { + sub: sub.to_string(), + role: role.to_string(), + exp: usize::MAX, + } + } + + async fn call_delete_comment( + pool: SqlitePool, + id: &str, + claims: auth::Claims, + ) -> Result)> { + delete_comment( + claims, + State(pool), + Path(id.to_string()), + crate::security::csrf::CsrfGuard, + ) + .await + } + + #[tokio::test] + async fn authenticated_user_can_delete_own_post_migration_comment() { + let pool = setup_comments_pool().await; + insert_comment_row(&pool, "c1", "bob", Some("bob"), Some(false), false).await; + + let result = call_delete_comment(pool, "c1", claims_for("bob", "user")).await; + + assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); + } + + /// Critical regression test: a guest can type any display name, + /// including a real, registered user's username. Before the + /// author_username/is_guest fix, `comment.author == claims.sub` would + /// let that real user delete the guest's comment. This must now be + /// rejected because the guest comment has no real identity attached. + #[tokio::test] + async fn authenticated_user_cannot_delete_guest_comment_with_spoofed_name() { + let pool = setup_comments_pool().await; + insert_comment_row(&pool, "c2", "bob", None, Some(true), false).await; + + let result = call_delete_comment(pool, "c2", claims_for("bob", "user")).await; + + let (status, _) = result.unwrap_err(); + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn admin_can_delete_any_comment_regardless_of_authorship() { + let pool = setup_comments_pool().await; + insert_comment_row( + &pool, + "c3", + "Administrator", + Some("realadminuser"), + Some(false), + true, + ) + .await; + + let result = call_delete_comment(pool, "c3", claims_for("different-admin", "admin")).await; + + assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn non_admin_cannot_delete_admin_authored_comment_even_with_same_username() { + let pool = setup_comments_pool().await; + insert_comment_row( + &pool, + "c7", + "Administrator", + Some("alice"), + Some(false), + true, + ) + .await; + + let result = call_delete_comment(pool, "c7", claims_for("alice", "user")).await; + + let (status, _) = result.unwrap_err(); + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn legacy_row_real_owner_can_still_self_delete() { + let pool = setup_comments_pool().await; + // Simulates a genuinely pre-migration row: both new columns are NULL. + insert_comment_row(&pool, "c4", "carol", None, None, false).await; + + let result = call_delete_comment(pool, "c4", claims_for("carol", "user")).await; + + assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); + } + + /// Documents the accepted, time-bounded residual risk: a pre-migration + /// row (author_username and is_guest both NULL) that was actually + /// posted by a guest who happened to type "carol" as their display name + /// can still be deleted by the real user "carol" via the legacy + /// fallback. This is intentional -- the alternative (blocking legacy + /// self-service deletion entirely) was rejected as a worse regression -- + /// and applies only to rows that existed before this fix shipped. + #[tokio::test] + async fn legacy_row_ambiguous_origin_is_a_known_accepted_gap() { + let pool = setup_comments_pool().await; + insert_comment_row(&pool, "c5", "carol", None, None, false).await; + + let result = call_delete_comment(pool, "c5", claims_for("carol", "user")).await; + + assert_eq!(result.unwrap(), StatusCode::NO_CONTENT); + } + + /// Guards the fail-closed arm added for the (author_username=None, + /// is_guest=Some(false)) state: no current insert path produces it (an + /// authenticated comment always sets author_username), but nothing in + /// the schema forbids it either. If a future bug or manual data edit + /// ever produces this shape, ownership must be rejected rather than + /// silently falling back to the spoofable `author == claims.sub` match. + #[tokio::test] + async fn inconsistent_row_with_no_username_but_marked_authenticated_is_rejected() { + let pool = setup_comments_pool().await; + insert_comment_row(&pool, "c6", "carol", None, Some(false), false).await; + + let result = call_delete_comment(pool, "c6", claims_for("carol", "user")).await; + + let (status, _) = result.unwrap_err(); + assert_eq!(status, StatusCode::FORBIDDEN); + } } diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index d2faeaab..00b01c6b 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -102,11 +102,25 @@ pub async fn upload_image( }; let normalized_ext = if ext == "jpeg" { "jpg" } else { ext.as_str() }; - // SECURITY: Reject if the content type (magic bytes) represents an extension we don't allow, - // or if it obviously contradicts the provided file extension. - if ALLOWED_EXTENSIONS.contains(&normalized_detected) - && normalized_detected != normalized_ext - { + // SECURITY: Reject outright if the detected content type is not one of our + // allowed image formats. This must be checked independently of the mismatch + // check below: a detected type outside the allowlist (e.g. exe, zip, pdf) + // would otherwise pass through unrejected and be saved under the client's + // claimed extension. + if !ALLOWED_EXTENSIONS.contains(&normalized_detected) { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!( + "Invalid file content. Detected type '{}' is not an allowed image format", + detected_ext + ), + }), + )); + } + + // SECURITY: Reject if the detected type contradicts the provided file extension. + if normalized_detected != normalized_ext { return Err(( StatusCode::BAD_REQUEST, Json(ErrorResponse { diff --git a/backend/src/middleware/auth.rs b/backend/src/middleware/auth.rs index 9f7d40b8..204da0af 100644 --- a/backend/src/middleware/auth.rs +++ b/backend/src/middleware/auth.rs @@ -49,7 +49,20 @@ pub async fn auth_middleware( // Step 3: Revocation Check (Blacklist) // Even a cryptographically valid token is rejected if the user has logged out. - if let Ok(true) = repositories::token_blacklist::is_token_blacklisted(&pool, &token).await { + // Fail CLOSED: a database error here must NOT be treated as "not blacklisted". + let is_blacklisted = repositories::token_blacklist::is_token_blacklisted(&pool, &token) + .await + .map_err(|e| { + tracing::error!("Database error checking token blacklist: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(crate::models::ErrorResponse { + error: "Internal server error".to_string(), + }), + ) + })?; + + if is_blacklisted { return Err(( StatusCode::UNAUTHORIZED, Json(crate::models::ErrorResponse { diff --git a/backend/src/middleware/security.rs b/backend/src/middleware/security.rs index 6ebfa70c..7df8a805 100644 --- a/backend/src/middleware/security.rs +++ b/backend/src/middleware/security.rs @@ -105,8 +105,7 @@ pub async fn strip_untrusted_forwarded_headers(mut request: Request, next: Next) /// Implementations: /// - **Cache-Control**: Dynamic based on path (public vs sensitive). /// - **CSP**: Strict policy to prevent XSS and data injection. -/// - **HSTS**: Enforce HTTPS for a year (if the request arrived via HTTPS -/// through a trusted proxy, or if ENABLE_HSTS=true is set). +/// - **HSTS**: Enforce HTTPS for a year (only if ENABLE_HSTS=true is set explicitly). /// - **X-Content-Type-Options**: Prevent MIME-sniffing. /// - **X-Frame-Options**: Prevent clickjacking. /// - **Referrer-Policy**: Protect user privacy during navigation. @@ -115,15 +114,6 @@ pub async fn security_headers(request: Request, next: Next) -> Response { let method = request.method().clone(); let path = request.uri().path().to_string(); - // Detect if request is over HTTPS for HSTS header - // We check the protocol usually injected by a trusted proxy - let is_https = request - .headers() - .get("x-forwarded-proto") // Note: This assumes strip_untrusted was ALREADY run and proxy injected it - .and_then(|v| v.to_str().ok()) - .map(|v| v == "https") - .unwrap_or(false); - let mut response = next.run(request).await; let headers = response.headers_mut(); @@ -165,11 +155,12 @@ pub async fn security_headers(request: Request, next: Next) -> Response { headers.insert(CONTENT_SECURITY_POLICY, HeaderValue::from_static(csp)); // Step 3: Transport Security (HSTS) - // The x-forwarded-proto check only works when proxy headers are trusted - // (TRUST_PROXY_IP_HEADERS=true); otherwise strip_untrusted_forwarded_headers - // removes the header before this middleware runs. ENABLE_HSTS lets - // deployments behind a TLS-terminating proxy opt in explicitly. - let hsts_enabled = is_https || parse_env_bool("ENABLE_HSTS", false); + // SECURITY: Do not auto-detect HTTPS via x-forwarded-proto. When + // TRUST_PROXY_IP_HEADERS=true, that header is not stripped before this + // middleware runs, so a client with direct access to the backend port + // could set it themselves. Require deployments behind a TLS-terminating + // proxy to opt in explicitly via ENABLE_HSTS instead. + let hsts_enabled = parse_env_bool("ENABLE_HSTS", false); if hsts_enabled { headers.insert( STRICT_TRANSPORT_SECURITY, diff --git a/backend/src/models/comment.rs b/backend/src/models/comment.rs index fd2571c8..2e680b07 100644 --- a/backend/src/models/comment.rs +++ b/backend/src/models/comment.rs @@ -22,4 +22,14 @@ pub struct Comment { pub votes: i64, /// Whether the comment author is an administrator. pub is_admin: bool, + /// Real authenticated username of the commenter. `None` for guest + /// comments and for pre-migration legacy rows (see `is_guest` to + /// disambiguate). NOT the same as `author`, which is a spoofable + /// free-text display name. Used for server-side ownership checks only. + #[serde(default)] + pub author_username: Option, + /// Tri-state: `Some(true)` = known guest comment, `Some(false)` = known + /// authenticated comment, `None` = pre-migration row of unknown origin. + #[serde(default)] + pub is_guest: Option, } diff --git a/backend/src/repositories/comments.rs b/backend/src/repositories/comments.rs index 425e4f1e..2aaf3bb3 100644 --- a/backend/src/repositories/comments.rs +++ b/backend/src/repositories/comments.rs @@ -12,7 +12,7 @@ pub async fn list_comments( ) -> Result, sqlx::Error> { // Dynamic query building for different sort orders let mut query_builder = sqlx::QueryBuilder::new( - "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin FROM comments WHERE tutorial_id = " + "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, author_username, is_guest FROM comments WHERE tutorial_id = " ); query_builder.push_bind(tutorial_id); @@ -44,7 +44,7 @@ pub async fn list_post_comments( sort: Option<&str>, ) -> Result, sqlx::Error> { let mut query_builder = sqlx::QueryBuilder::new( - "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin FROM comments WHERE post_id = " + "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, author_username, is_guest FROM comments WHERE post_id = " ); query_builder.push_bind(post_id); @@ -79,9 +79,11 @@ pub async fn create_comment( content: &str, created_at: &str, is_admin: bool, + author_username: Option, + is_guest: Option, ) -> Result { sqlx::query( - "INSERT INTO comments (id, tutorial_id, post_id, author, rate_limit_key, content, created_at, votes, is_admin) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)" + "INSERT INTO comments (id, tutorial_id, post_id, author, rate_limit_key, content, created_at, votes, is_admin, author_username, is_guest) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)" ) .bind(id) .bind(&tutorial_id) @@ -91,6 +93,8 @@ pub async fn create_comment( .bind(content) .bind(created_at) .bind(is_admin) + .bind(&author_username) + .bind(is_guest) .execute(pool) .await?; @@ -103,12 +107,14 @@ pub async fn create_comment( created_at: created_at.to_string(), votes: 0, is_admin, + author_username, + is_guest, }) } pub async fn get_comment(pool: &DbPool, id: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Comment>( - "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin FROM comments WHERE id = ?", + "SELECT id, tutorial_id, post_id, author, content, created_at, votes, is_admin, author_username, is_guest FROM comments WHERE id = ?", ) .bind(id) .fetch_optional(pool) diff --git a/backend/src/repositories/token_blacklist.rs b/backend/src/repositories/token_blacklist.rs index e4ead262..b42b13fe 100644 --- a/backend/src/repositories/token_blacklist.rs +++ b/backend/src/repositories/token_blacklist.rs @@ -1,6 +1,18 @@ use crate::db::DbPool; +use crate::security::sha256_hex; use sqlx; +/// Hashes a raw JWT before it is stored in or queried against the blacklist. +/// +/// The blacklist must never persist raw, reusable session tokens: anyone who +/// reads the SQLite file (backup, restore leak, export) would otherwise gain +/// directly reusable session artifacts. Storing only the SHA-256 hash keeps +/// the revocation check working (equality is preserved under hashing) while +/// making the stored value useless for session hijacking. +fn hash_token(token: &str) -> String { + sha256_hex(token.as_bytes()) +} + /// Adds a JWT to the blacklist to invalidate it before its natural expiration. /// Used during logout or security revocation. pub async fn blacklist_token( @@ -15,7 +27,7 @@ pub async fn blacklist_token( .to_rfc3339(); sqlx::query("INSERT INTO token_blacklist (token, expires_at) VALUES (?, ?)") - .bind(token) + .bind(hash_token(token)) .bind(expires_at_str) .execute(pool) .await?; @@ -25,7 +37,7 @@ pub async fn blacklist_token( pub async fn is_token_blacklisted(pool: &DbPool, token: &str) -> Result { let exists: Option<(String,)> = sqlx::query_as("SELECT token FROM token_blacklist WHERE token = ?") - .bind(token) + .bind(hash_token(token)) .fetch_optional(pool) .await?; Ok(exists.is_some()) @@ -38,3 +50,48 @@ pub async fn cleanup_expired(pool: &DbPool) -> Result { .await?; Ok(result.rows_affected()) } + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + + async fn setup_test_db() -> DbPool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + crate::db::migrations::run_migrations(&pool) + .await + .expect("Failed to run migrations"); + pool + } + + #[tokio::test] + async fn blacklisted_token_is_detected_after_hashing() { + let pool = setup_test_db().await; + let token = "some.raw.jwt.value"; + + assert!(!is_token_blacklisted(&pool, token).await.unwrap()); + + let expires_at = chrono::Utc::now().timestamp() + 3600; + blacklist_token(&pool, token, expires_at).await.unwrap(); + + assert!(is_token_blacklisted(&pool, token).await.unwrap()); + } + + #[tokio::test] + async fn stored_value_is_hashed_not_plaintext() { + let pool = setup_test_db().await; + let token = "another.raw.jwt.value"; + let expires_at = chrono::Utc::now().timestamp() + 3600; + + blacklist_token(&pool, token, expires_at).await.unwrap(); + + let stored: (String,) = sqlx::query_as("SELECT token FROM token_blacklist WHERE token = ?") + .bind(hash_token(token)) + .fetch_one(&pool) + .await + .expect("hashed row must exist"); + + assert_ne!(stored.0, token, "raw token must never be stored"); + assert_eq!(stored.0, hash_token(token)); + } +} diff --git a/backend/src/routes/api.rs b/backend/src/routes/api.rs index 6e8e09fa..727af5ad 100644 --- a/backend/src/routes/api.rs +++ b/backend/src/routes/api.rs @@ -23,6 +23,17 @@ pub fn routes( _admin_rate_limit_config: Arc>, public_rate_limit_config: Arc>, ) -> Router { + // Grouped so both endpoints share the same rate limit: voting has no + // dedicated limit of its own and would otherwise be callable at + // unlimited frequency by any authenticated client. + let rate_limited_comment_routes = Router::new() + .route( + "/api/posts/{id}/comments", + get(comments::list_post_comments).post(comments::create_post_comment), + ) + .route("/api/comments/{id}/vote", post(comments::vote_comment)) + .route_layer(GovernorLayer::new(public_rate_limit_config)); + Router::new() .route("/api/auth/me", get(auth::me)) .route("/api/tutorials", get(tutorials::list_tutorials)) @@ -35,13 +46,7 @@ pub fn routes( "/api/content/{section}", get(site_content::get_site_content), ) - .route( - "/api/posts/{id}/comments", - get(comments::list_post_comments) - .post(comments::create_post_comment) - .route_layer(GovernorLayer::new(public_rate_limit_config)), - ) - .route("/api/comments/{id}/vote", post(comments::vote_comment)) + .merge(rate_limited_comment_routes) .route( "/api/public/pages/{slug}", get(site_pages::get_published_page_by_slug), diff --git a/backend/src/security/mod.rs b/backend/src/security/mod.rs index 762117d4..3e8d349b 100644 --- a/backend/src/security/mod.rs +++ b/backend/src/security/mod.rs @@ -3,5 +3,19 @@ //! This module implements core security primitives including identity //! management (JWT) and request integrity (CSRF). +use sha2::{Digest, Sha256}; + pub mod auth; // JWT token lifecycle and verification pub mod csrf; // Double-submit cookie CSRF protection + +/// Returns the lowercase hex-encoded SHA-256 digest of `data`. +/// +/// Shared primitive for callers that need a one-way digest (e.g. hashing a +/// login identifier before rate-limit lookups, or hashing a JWT before it is +/// persisted in the token blacklist) — keeps the hashing recipe in one place +/// instead of every caller re-implementing `Sha256::new/update/finalize`. +pub fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + format!("{:x}", hasher.finalize()) +} diff --git a/backend/tests/auth_middleware_tests.rs b/backend/tests/auth_middleware_tests.rs new file mode 100644 index 00000000..3f2be859 --- /dev/null +++ b/backend/tests/auth_middleware_tests.rs @@ -0,0 +1,113 @@ +use axum::{ + body::Body, + extract::ConnectInfo, + http::{Request, StatusCode}, +}; +use rust_blog_backend::{db, handlers, routes, security::auth}; +use sqlx::SqlitePool; +use std::env; +use std::net::SocketAddr; +use tower::ServiceExt; // for `oneshot` + +fn deterministic_test_material(label: &str, min_len: usize) -> String { + let alphabet = "abcABC123!@#xyzXYZ789"; + let mut value = format!("not-real-{label}-"); + while value.len() < min_len { + value.push_str(alphabet); + } + value +} + +fn init_test_secrets() { + env::set_var( + "LOGIN_ATTEMPT_SALT", + deterministic_test_material("login-salt", 32), + ); + let _ = handlers::auth::init_login_attempt_salt(); + + env::set_var("JWT_SECRET", deterministic_test_material("jwt-signing", 43)); + let _ = auth::init_jwt_secret(); + + env::set_var("CSRF_SECRET", deterministic_test_material("csrf-hmac", 32)); + let _ = rust_blog_backend::security::csrf::init_csrf_secret(); +} + +fn with_connect_info(mut request: Request) -> Request { + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 3000)))); + request +} + +/// Regression test for the fail-open blacklist bug: a database error while +/// checking token revocation must reject the request (fail closed), not +/// silently treat the token as valid (fail open). +#[tokio::test] +async fn blacklist_db_error_fails_closed_with_500_not_passthrough() { + init_test_secrets(); + + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + db::migrations::run_migrations(&pool) + .await + .expect("Failed to run migrations"); + + let token = auth::create_jwt("admin".to_string(), "admin".to_string()).unwrap(); + + // Simulate a database failure specific to the blacklist check. + sqlx::query("DROP TABLE token_blacklist") + .execute(&pool) + .await + .unwrap(); + + let app = routes::create_routes(pool.clone(), "test_uploads".to_string()).with_state(pool); + + // Use a GET admin route so CSRF enforcement (which only applies to + // state-changing methods) doesn't interfere with isolating the + // auth_middleware behavior under test. + let response = app + .oneshot(with_connect_info( + Request::builder() + .method("GET") + .uri("/api/pages/nonexistent-id") + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +/// Companion test guarding against overcorrection: with the blacklist table +/// intact and the token not revoked, the request must pass through the +/// middleware normally (reaching the handler), not be blocked unconditionally. +#[tokio::test] +async fn valid_non_blacklisted_token_passes_middleware() { + init_test_secrets(); + + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + db::migrations::run_migrations(&pool) + .await + .expect("Failed to run migrations"); + + let token = auth::create_jwt("admin".to_string(), "admin".to_string()).unwrap(); + + let app = routes::create_routes(pool.clone(), "test_uploads".to_string()).with_state(pool); + + let response = app + .oneshot(with_connect_info( + Request::builder() + .method("GET") + .uri("/api/pages/nonexistent-id") + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + )) + .await + .unwrap(); + + // The middleware let the request through; the handler then reports the + // page doesn't exist. This must NOT be 401/500 from the middleware. + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/backend/tests/comment_model_tests.rs b/backend/tests/comment_model_tests.rs index 53d5c032..4b6895f5 100644 --- a/backend/tests/comment_model_tests.rs +++ b/backend/tests/comment_model_tests.rs @@ -12,12 +12,16 @@ fn test_comment_serialization() { created_at: "2023-01-01".to_string(), votes: 10, is_admin: false, + author_username: None, + is_guest: None, }; let serialized = serde_json::to_string(&comment).unwrap(); assert!(serialized.contains("\"id\":\"c1\"")); assert!(serialized.contains("\"tutorial_id\":\"t1\"")); assert!(serialized.contains("\"post_id\":null")); + assert!(serialized.contains("\"author_username\":null")); + assert!(serialized.contains("\"is_guest\":null")); } #[test] @@ -33,8 +37,12 @@ fn test_comment_deserialization() { "is_admin": true }); + // author_username/is_guest are intentionally omitted from the fixture to + // confirm #[serde(default)] keeps deserialization backward compatible. let comment: Comment = serde_json::from_value(data).unwrap(); assert_eq!(comment.author, "User"); assert!(comment.is_admin); assert_eq!(comment.post_id, Some("p1".to_string())); + assert_eq!(comment.author_username, None); + assert_eq!(comment.is_guest, None); } diff --git a/package-lock.json b/package-lock.json index 13bf372e..65204480 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "react-router-dom": "^7.9.5", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", - "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", @@ -4585,31 +4584,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-raw": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", - "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-from-parse5": "^8.0.0", - "hast-util-to-parse5": "^8.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "parse5": "^7.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -4637,35 +4611,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", - "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "property-information": "^6.0.0", - "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-parse5/node_modules/property-information": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", - "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/hast-util-to-text": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", @@ -4777,16 +4722,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -8008,21 +7943,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/rehype-raw": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", - "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-raw": "^9.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/remark-breaks": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", diff --git a/package.json b/package.json index 85335cee..9e1f78a1 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "react-router-dom": "^7.9.5", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", - "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", @@ -124,4 +123,4 @@ "react-dom": "$react-dom" } } -} \ No newline at end of file +}