From b3c916c26fd1fd798d42cc7761043f92f8a03be0 Mon Sep 17 00:00:00 2001 From: sousuke0422 Date: Tue, 14 Jul 2026 21:42:52 +0900 Subject: [PATCH 1/5] fix(security): harden XSS, canvas errors, and Wasm input limits Escape quotes in SVG attributes, return safe empty SVG on parse failure, serialize canvas errors with serde_json, clear canvas on failure paths, pin chromaui/action to a fixed SHA, and cap Wasm task/dep/date inputs. Assisted-by: Cursor Co-authored-by: Cursor Assisted-by: multi-agent-shogun-aki-tweak --- .github/workflows/chromatic.yml | 2 +- crates/koyori-arc-core/src/backend/svg.rs | 27 ++++ crates/koyori-arc-core/src/render.rs | 135 +++++++++++++++++++- crates/koyori-arc-core/tests/wasm.rs | 14 +- packages/arc-vue/src/GanttChart.vue | 24 +++- packages/arc-vue/src/replayCommands.test.ts | 6 + packages/arc-vue/src/replayCommands.ts | 12 +- 7 files changed, 206 insertions(+), 14 deletions(-) diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index e6b0543..37f59a9 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -42,7 +42,7 @@ jobs: working-directory: packages/arc-vue - name: Publish to Chromatic - uses: chromaui/action@latest + uses: chromaui/action@94713c544284a14195de3b50ef24301579f1877e # v13.3.0 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} workingDir: packages/arc-vue diff --git a/crates/koyori-arc-core/src/backend/svg.rs b/crates/koyori-arc-core/src/backend/svg.rs index ad380b0..ede9bf4 100644 --- a/crates/koyori-arc-core/src/backend/svg.rs +++ b/crates/koyori-arc-core/src/backend/svg.rs @@ -241,6 +241,8 @@ fn render_text(svg: &mut String, t: &TextPrim, palette: &Palette) { fn escape_xml(s: &str) -> String { s.replace('&', "&") + .replace('"', """) + .replace('\'', "'") .replace('<', "<") .replace('>', ">") } @@ -248,3 +250,28 @@ fn escape_xml(s: &str) -> String { pub fn empty_svg() -> String { r#"Empty Gantt chartNo tasks to display"#.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escape_xml_quotes_in_attribute_context() { + let malicious = "x\" onmouseover=\"alert(1)\""; + let escaped = escape_xml(malicious); + assert!(!escaped.contains('"')); + assert!(escaped.contains(""")); + assert_eq!( + escaped, + "x" onmouseover="alert(1)"" + ); + } + + #[test] + fn data_task_id_attribute_is_safe() { + let id = "x\" onmouseover=\"alert(1)\""; + let fragment = format!(r#""#, escape_xml(id)); + assert!(fragment.contains("data-task-id=\"x" onmouseover="alert(1)"\"")); + assert!(!fragment.contains(r#"onmouseover="alert"#)); + } +} diff --git a/crates/koyori-arc-core/src/render.rs b/crates/koyori-arc-core/src/render.rs index f4af517..f96439a 100644 --- a/crates/koyori-arc-core/src/render.rs +++ b/crates/koyori-arc-core/src/render.rs @@ -2,9 +2,41 @@ use chrono::NaiveDate; use wasm_bindgen::prelude::*; use crate::backend::{BackendOutput, CanvasBackend, CommandBuffer, RenderBackend, SvgBackend}; +use crate::backend::svg::empty_svg; use crate::display_list::{build_display_list, types::Palette, ScrollViewport}; use crate::graph::{GanttDep, GanttGraph, GanttTask}; +/// Upper bounds enforced at Wasm entry points to limit memory/CPU abuse. +pub const MAX_TASKS: usize = 10_000; +pub const MAX_DEPS: usize = 100_000; +pub const MAX_DATE_SPAN_DAYS: i64 = 3_650; + +fn json_error(msg: impl Into) -> String { + serde_json::json!({ "error": msg.into() }).to_string() +} + +fn graph_limit_error(tasks: &[GanttTask], deps: &[GanttDep]) -> Option { + if tasks.len() > MAX_TASKS { + return Some(format!("task count exceeds limit ({MAX_TASKS})")); + } + if deps.len() > MAX_DEPS { + return Some(format!("dependency count exceeds limit ({MAX_DEPS})")); + } + if tasks.is_empty() { + return None; + } + let min_start = tasks.iter().map(|t| t.start).min().unwrap(); + let max_date = tasks + .iter() + .map(|t| t.end.unwrap_or(t.start)) + .max() + .unwrap(); + if (max_date - min_start).num_days() > MAX_DATE_SPAN_DAYS { + return Some(format!("date range exceeds limit ({MAX_DATE_SPAN_DAYS} days)")); + } + None +} + /// Native entry point — accepts typed structs directly. pub fn render( tasks: &[GanttTask], @@ -68,12 +100,15 @@ pub fn render_svg( ) -> String { let tasks: Vec = match serde_json::from_str(tasks_json) { Ok(v) => v, - Err(e) => return format!(""), + Err(_) => return empty_svg(), }; let deps: Vec = match serde_json::from_str(deps_json) { Ok(v) => v, - Err(e) => return format!(""), + Err(_) => return empty_svg(), }; + if graph_limit_error(&tasks, &deps).is_some() { + return empty_svg(); + } let today = today_iso.and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); let scroll_viewport = viewport_json.and_then(|s| serde_json::from_str(&s).ok()); render(&tasks, &deps, today, scroll_viewport) @@ -90,16 +125,19 @@ pub fn render_canvas_commands( ) -> String { let tasks: Vec = match serde_json::from_str(tasks_json) { Ok(v) => v, - Err(e) => return format!(r#"{{"error":"parse error: {e}"}}"#), + Err(e) => return json_error(format!("parse error: {e}")), }; let deps: Vec = match serde_json::from_str(deps_json) { Ok(v) => v, - Err(e) => return format!(r#"{{"error":"parse error: {e}"}}"#), + Err(e) => return json_error(format!("parse error: {e}")), }; + if let Some(msg) = graph_limit_error(&tasks, &deps) { + return json_error(msg); + } let today = today_iso.and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); let scroll_viewport = viewport_json.and_then(|s| serde_json::from_str(&s).ok()); let buffer = render_canvas(&tasks, &deps, today, scroll_viewport); - serde_json::to_string(&buffer).unwrap_or_else(|e| format!(r#"{{"error":"serialize error: {e}"}}"#)) + serde_json::to_string(&buffer).unwrap_or_else(|e| json_error(format!("serialize error: {e}"))) } #[cfg(test)] @@ -239,9 +277,92 @@ mod tests { } #[test] - fn parse_error_returns_comment() { + fn parse_error_returns_safe_empty_svg() { let svg = render_svg("not json", "[]", None, None); - assert!(svg.starts_with(" -
+
@@ -203,6 +263,10 @@ function onCanvasClick(e: MouseEvent) { position: relative; width: 100%; } +.koyori-gantt-error { + padding: 12px; + color: #991b1b; +} .koyori-gantt-svg { position: absolute; top: 0; diff --git a/packages/arc-vue/src/canvasFallback.test.ts b/packages/arc-vue/src/canvasFallback.test.ts new file mode 100644 index 0000000..6a1c6d1 --- /dev/null +++ b/packages/arc-vue/src/canvasFallback.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { chartHeightForTaskCount, resolveCanvasFailure } from './canvasFallback'; + +describe('resolveCanvasFailure', () => { + it.each([408, 1_000, 10_000])( + 'uses a non-empty SVG fallback when %i tasks exceed Canvas capacity', + (taskCount) => { + const height = chartHeightForTaskCount(taskCount); + const svg = ``; + expect(resolveCanvasFailure( + 'canvas row count exceeds limit (407 rows / 16384px)', + svg, + )).toEqual({ mode: 'svg', svg }); + expect(svg).toContain(`height="${height}"`); + }, + ); + + it('resets the scroll domain for empty content', () => { + expect(chartHeightForTaskCount(0)).toBe(0); + expect(chartHeightForTaskCount(408)).toBe(16_400); + }); + + it('shows an error when the shared task limit rejects SVG too', () => { + expect(resolveCanvasFailure( + 'task count exceeds limit (10000)', + '', + )).toEqual({ mode: 'error', message: 'task count exceeds limit (10000)' }); + }); + + it('shows an error when SVG fallback unexpectedly renders empty', () => { + expect(resolveCanvasFailure( + 'canvas area exceeds limit (33554432 pixels)', + '', + )).toEqual({ + mode: 'error', + message: 'canvas area exceeds limit (33554432 pixels)', + }); + }); +}); diff --git a/packages/arc-vue/src/canvasFallback.ts b/packages/arc-vue/src/canvasFallback.ts new file mode 100644 index 0000000..eac7ab5 --- /dev/null +++ b/packages/arc-vue/src/canvasFallback.ts @@ -0,0 +1,26 @@ +export type CanvasFailureResolution = + | { mode: 'svg'; svg: string } + | { mode: 'error'; message: string }; + +export function chartHeightForTaskCount(taskCount: number): number { + return taskCount === 0 ? 0 : taskCount * 40 + 30 + 40 + 10; +} + +/** Canvas capacity errors can use the independently bounded SVG renderer. */ +export function isCanvasCapacityError(message: string): boolean { + return /^canvas (row count|date range|area) exceeds limit/.test(message); +} + +export function resolveCanvasFailure( + message: string, + fallbackSvg: string, +): CanvasFailureResolution { + if ( + isCanvasCapacityError(message) + && fallbackSvg.includes(' Date: Wed, 15 Jul 2026 03:06:37 +0900 Subject: [PATCH 5/5] fix(security): reject oversized input by raw byte size before deserialization Assisted-by: multi-agent-shogun-aki-tweak --- crates/koyori-arc-core/src/render.rs | 83 +++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/koyori-arc-core/src/render.rs b/crates/koyori-arc-core/src/render.rs index ce88620..5ba2b2c 100644 --- a/crates/koyori-arc-core/src/render.rs +++ b/crates/koyori-arc-core/src/render.rs @@ -1,8 +1,8 @@ use chrono::{Duration, NaiveDate}; use wasm_bindgen::prelude::*; -use crate::backend::{BackendOutput, CanvasBackend, CommandBuffer, RenderBackend, SvgBackend}; use crate::backend::svg::empty_svg; +use crate::backend::{BackendOutput, CanvasBackend, CommandBuffer, RenderBackend, SvgBackend}; use crate::display_list::constants::{HEADER_H, LABEL_W, LEGEND_H, PX_PER_DAY, ROW_H}; use crate::display_list::{build_display_list, types::Palette, ScrollViewport}; use crate::graph::{GanttDep, GanttGraph, GanttTask}; @@ -10,6 +10,12 @@ use crate::graph::{GanttDep, GanttGraph, GanttTask}; /// Upper bounds enforced at Wasm entry points to limit memory/CPU abuse. pub const MAX_TASKS: usize = 10_000; pub const MAX_DEPS: usize = 100_000; +/// Allows about 839 bytes per task at `MAX_TASKS`, enough for realistic IDs and +/// titles plus JSON overhead while rejecting multi-megabyte individual fields. +pub const MAX_TASKS_JSON_BYTES: usize = 8 * 1024 * 1024; +/// Allows about 167 bytes per dependency at `MAX_DEPS`, leaving ample room for +/// two realistic task IDs and JSON overhead without accepting unbounded input. +pub const MAX_DEPS_JSON_BYTES: usize = 16 * 1024 * 1024; pub const MAX_DATE_SPAN_DAYS: i64 = 3_650; /// Conservative cross-browser Canvas2D backing-store edge limit. Browser and @@ -36,6 +42,20 @@ fn json_error(msg: impl Into) -> String { serde_json::json!({ "error": msg.into() }).to_string() } +fn raw_json_limit_error(tasks_json: &str, deps_json: &str) -> Option { + if tasks_json.len() > MAX_TASKS_JSON_BYTES { + return Some(format!( + "tasks JSON byte size exceeds limit ({MAX_TASKS_JSON_BYTES})" + )); + } + if deps_json.len() > MAX_DEPS_JSON_BYTES { + return Some(format!( + "dependencies JSON byte size exceeds limit ({MAX_DEPS_JSON_BYTES})" + )); + } + None +} + fn common_graph_limit_error(tasks: &[GanttTask], deps: &[GanttDep]) -> Option { if tasks.len() > MAX_TASKS { return Some(format!("task count exceeds limit ({MAX_TASKS})")); @@ -159,6 +179,9 @@ pub fn render_svg( today_iso: Option, viewport_json: Option, ) -> String { + if raw_json_limit_error(tasks_json, deps_json).is_some() { + return empty_svg(); + } let tasks: Vec = match serde_json::from_str(tasks_json) { Ok(v) => v, Err(_) => return empty_svg(), @@ -184,6 +207,9 @@ pub fn render_canvas_commands( today_iso: Option, viewport_json: Option, ) -> String { + if let Some(msg) = raw_json_limit_error(tasks_json, deps_json) { + return json_error(msg); + } let tasks: Vec = match serde_json::from_str(tasks_json) { Ok(v) => v, Err(e) => return json_error(format!("parse error: {e}")), @@ -381,6 +407,61 @@ mod tests { assert!(v.get("error").is_some()); } + #[test] + fn raw_byte_limits_reject_tasks_before_deserialization() { + // Deliberately invalid JSON: the byte-limit error proves parsing was not attempted. + let oversized_tasks = "x".repeat(MAX_TASKS_JSON_BYTES + 1); + let svg = render_svg(&oversized_tasks, "[]", None, None); + assert_eq!(svg, crate::backend::svg::empty_svg()); + + let json = render_canvas_commands(&oversized_tasks, "[]", None, None); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(value["error"] + .as_str() + .unwrap() + .contains("tasks JSON byte size")); + assert!(!value["error"].as_str().unwrap().contains("parse error")); + } + + #[test] + fn raw_byte_limits_reject_dependencies_before_deserialization() { + // Deliberately invalid JSON: the byte-limit error proves parsing was not attempted. + let oversized_deps = "x".repeat(MAX_DEPS_JSON_BYTES + 1); + let svg = render_svg("[]", &oversized_deps, None, None); + assert_eq!(svg, crate::backend::svg::empty_svg()); + + let json = render_canvas_commands("[]", &oversized_deps, None, None); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(value["error"] + .as_str() + .unwrap() + .contains("dependencies JSON byte size")); + assert!(!value["error"].as_str().unwrap().contains("parse error")); + } + + #[test] + fn raw_byte_limit_rejects_single_giant_title() { + let giant_title = "x".repeat(MAX_TASKS_JSON_BYTES); + let tasks = serde_json::json!([{ + "id": "giant-title", + "title": giant_title, + "progress_pct": 0, + "start": "2026-06-01", + "end": "2026-06-02" + }]) + .to_string(); + assert!(tasks.len() > MAX_TASKS_JSON_BYTES); + + let svg = render_svg(&tasks, "[]", None, None); + assert_eq!(svg, crate::backend::svg::empty_svg()); + let json = render_canvas_commands(&tasks, "[]", None, None); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(value["error"] + .as_str() + .unwrap() + .contains("tasks JSON byte size")); + } + #[test] fn graph_limit_rejects_excessive_tasks() { let tasks: Vec = (0..=super::MAX_TASKS)