max / makenotwork
3 files changed,
+85 insertions,
-11 deletions
| @@ -323,12 +323,24 @@ pub async fn fail_stale_running_builds(pool: &PgPool, timeout_secs: i64) -> Resu | |||
| 323 | 323 | Ok(result.rows_affected()) | |
| 324 | 324 | } | |
| 325 | 325 | ||
| 326 | - | /// Append a line to the build log. | |
| 326 | + | /// Hard ceiling on the `log` column, enforced at the SQL layer in | |
| 327 | + | /// [`append_build_log`] so no caller can grow it without bound. Set above the | |
| 328 | + | /// 5 MiB soft cap that `build_runner::append_log_bounded` applies (with its | |
| 329 | + | /// `[log truncated]` marker), so this is a pure backstop: the soft cap fires | |
| 330 | + | /// first for the normal path, and this guarantees the invariant even for a | |
| 331 | + | /// future caller that bypasses the orchestrator. | |
| 332 | + | const HARD_LOG_CAP_BYTES: i64 = 8 * 1024 * 1024; | |
| 333 | + | ||
| 334 | + | /// Append a line to the build log, never letting the column exceed | |
| 335 | + | /// [`HARD_LOG_CAP_BYTES`]. The `octet_length` guard makes unbounded growth | |
| 336 | + | /// unwritable regardless of caller — the bound lives in this one function, not | |
| 337 | + | /// in a convention each call site must remember. | |
| 327 | 338 | #[tracing::instrument(skip_all)] | |
| 328 | 339 | pub async fn append_build_log(pool: &PgPool, build_id: BuildId, line: &str) -> Result<()> { | |
| 329 | - | sqlx::query("UPDATE ota_builds SET log = log || $2 WHERE id = $1") | |
| 340 | + | sqlx::query("UPDATE ota_builds SET log = log || $2 WHERE id = $1 AND octet_length(log) < $3") | |
| 330 | 341 | .bind(build_id) | |
| 331 | 342 | .bind(line) | |
| 343 | + | .bind(HARD_LOG_CAP_BYTES) | |
| 332 | 344 | .execute(pool) | |
| 333 | 345 | .await?; | |
| 334 | 346 |
| @@ -253,17 +253,33 @@ pub(super) async fn media_confirm( | |||
| 253 | 253 | Err(e) => return Err(AppError::Storage(format!("read media header from S3: {e}"))), | |
| 254 | 254 | } | |
| 255 | 255 | } | |
| 256 | - | let detected = infer::get(&head).and_then(|kind| match kind.matcher_type() { | |
| 257 | - | infer::MatcherType::Image => Some("image"), | |
| 258 | - | infer::MatcherType::Video => Some("video"), | |
| 259 | - | _ => None, | |
| 260 | - | }); | |
| 261 | - | if let Some(real) = detected | |
| 262 | - | && real != media_type | |
| 263 | - | { | |
| 256 | + | let detected = infer::get(&head).map(|kind| kind.matcher_type()); | |
| 257 | + | // `media_type` is only ever "image" or "video" (see `classify_media`). | |
| 258 | + | let mismatch = match media_type { | |
| 259 | + | // Every allowed image format (jpeg/png/webp/gif) is positively | |
| 260 | + | // detected by `infer`, so a declared image MUST sniff as an image. | |
| 261 | + | // Requiring a positive image signature — rather than only rejecting | |
| 262 | + | // a *detected* video — closes the bypass where a video `infer` | |
| 263 | + | // cannot name its container is declared `image/png` to dodge the | |
| 264 | + | // BigFiles+ video-tier gate (Run #22 / Run #5 Storage): a video | |
| 265 | + | // never sniffs as Image, so it is rejected here either way. | |
| 266 | + | "image" => detected != Some(infer::MatcherType::Image), | |
| 267 | + | // Declared video: do NOT require a positive video signature — | |
| 268 | + | // `infer` cannot classify every valid container (fragmented mp4, | |
| 269 | + | // some mov/webm), and there is no tier-evasion incentive to declare | |
| 270 | + | // a video as a video. Only reject a still image mislabeled as video. | |
| 271 | + | "video" => detected == Some(infer::MatcherType::Image), | |
| 272 | + | _ => false, | |
| 273 | + | }; | |
| 274 | + | if mismatch { | |
| 264 | 275 | super::enqueue_s3_orphan(&state.db, &req.s3_key, "media_content_type_mismatch").await; | |
| 276 | + | let sniffed = match detected { | |
| 277 | + | Some(infer::MatcherType::Image) => "image", | |
| 278 | + | Some(infer::MatcherType::Video) => "video", | |
| 279 | + | _ => "an unrecognized format", | |
| 280 | + | }; | |
| 265 | 281 | return Err(AppError::BadRequest(format!( | |
| 266 | - | "Uploaded file is a {real}, but was declared as a {media_type}." | |
| 282 | + | "Uploaded file does not match the declared {media_type} type (detected: {sniffed})." | |
| 267 | 283 | ))); | |
| 268 | 284 | } | |
| 269 | 285 | } |
| @@ -137,6 +137,52 @@ async fn video_declared_as_image_is_rejected_at_confirm() { | |||
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | 139 | // --------------------------------------------------------------------------- | |
| 140 | + | // Bytes `infer` cannot classify, declared as an image, are rejected at confirm | |
| 141 | + | // --------------------------------------------------------------------------- | |
| 142 | + | ||
| 143 | + | #[tokio::test] | |
| 144 | + | async fn unrecognized_bytes_declared_as_image_is_rejected_at_confirm() { | |
| 145 | + | // Run #5 Storage S1: the old sniff only rejected a *positively detected* | |
| 146 | + | // video, so a video whose container `infer` cannot name slipped through an | |
| 147 | + | // `image/png` declaration. Declared images must now sniff positively as an | |
| 148 | + | // image; unrecognized bytes are rejected, closing the bypass. | |
| 149 | + | let mut h = TestHarness::with_storage().await; | |
| 150 | + | setup_basic_creator(&mut h, "unrecimg").await; | |
| 151 | + | ||
| 152 | + | let body = json!({ | |
| 153 | + | "file_name": "blob.png", | |
| 154 | + | "content_type": "image/png", | |
| 155 | + | "folder": "clips", | |
| 156 | + | }); | |
| 157 | + | let resp = h.client.post_json("/api/media/presign", &body.to_string()).await; | |
| 158 | + | assert!(resp.status.is_success(), "image presign should succeed: {}", resp.text); | |
| 159 | + | let presign: Value = resp.json(); | |
| 160 | + | let s3_key = presign["s3_key"].as_str().unwrap().to_string(); | |
| 161 | + | ||
| 162 | + | // Bytes `infer` does not recognize as any image or video format. | |
| 163 | + | h.storage.as_ref().unwrap().put(&s3_key, b"not a real image at all".to_vec()); | |
| 164 | + | ||
| 165 | + | let body = json!({ | |
| 166 | + | "s3_key": s3_key, | |
| 167 | + | "file_name": "blob.png", | |
| 168 | + | "content_type": "image/png", | |
| 169 | + | "folder": "clips", | |
| 170 | + | }); | |
| 171 | + | let resp = h.client.post_json("/api/media/confirm", &body.to_string()).await; | |
| 172 | + | assert!( | |
| 173 | + | !resp.status.is_success(), | |
| 174 | + | "unrecognized bytes declared as an image must be rejected, got: {} {}", | |
| 175 | + | resp.status, resp.text | |
| 176 | + | ); | |
| 177 | + | ||
| 178 | + | let list: Value = h.client.get("/api/media?folder=clips").await.json(); | |
| 179 | + | assert!( | |
| 180 | + | list["files"].as_array().map(|f| f.is_empty()).unwrap_or(true), | |
| 181 | + | "rejected upload must not appear in the media library" | |
| 182 | + | ); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // --------------------------------------------------------------------------- | |
| 140 | 186 | // Image upload on Basic tier succeeds (images bypass tier check like covers) | |
| 141 | 187 | // --------------------------------------------------------------------------- | |
| 142 | 188 |