From 079fcd2631216147db4b68acbcaca58ac4f93fb4 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 23 Jul 2026 00:18:16 +0800 Subject: [PATCH] fix(security): close the gaps a full 2.0 re-audit left open (#620) Follow-up to a full re-audit of the 2.0 tree. Most prior findings were already fixed; this closes the ones that were not: - SAML assertion replay: validateInResponseTo ifPresent plus a Redis-backed CacheProvider, so a captured signed assertion cannot be replayed. ifPresent keeps IdP-initiated SSO working. - MFA login challenge burned after 5 wrong TOTP codes. - api_keys.key_prefix indexed; the per-request lookup was a full table scan. - MAX_AI_JOBS_PER_USER caps a user's in-flight single-file AI jobs (the AI pool runs at concurrency 1). Batch and pipeline AI stay uncapped. - MAX_WORKSPACE_SIZE_GB enforced instead of being dead config. - SUBPROCESS_MEMORY_LIMIT_MB (default off) for the native media and doc engines; not applied to the AI sidecar. - SVG sanitizer closes unquoted and whitespace-prefixed javascript: hrefs and the animateTransform/animateMotion/handler/mpath elements. - Windows-style paths stripped from error output to match the Sentry scrubber. - Postgres and Redis compose services get cap_drop plus pids_limit and cpus. - .env.example ships MAX_SVG_SIZE_MB=50 (0 disabled the cap). Adds security-focused unit and integration tests. typecheck, biome, and the full unit and integration suites pass. --- .env.example | 8 +- .../drizzle/0006_majestic_sinister_six.sql | 1 + apps/api/drizzle/meta/0006_snapshot.json | 992 ++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 37 +- apps/api/src/jobs/enqueue.ts | 8 + apps/api/src/lib/ai-quota.ts | 51 + apps/api/src/lib/env.ts | 10 + apps/api/src/lib/errors.ts | 19 +- apps/api/src/lib/object-storage.ts | 65 ++ apps/api/src/lib/saml-cache.ts | 39 + apps/api/src/lib/svg-sanitize.ts | 35 +- apps/api/src/plugins/mfa.ts | 17 +- apps/api/src/plugins/saml.ts | 14 +- docker/docker-compose-gpu.yml | 30 + docker/docker-compose.yml | 30 + packages/doc-engine/src/ghostscript.ts | 4 +- packages/doc-engine/src/libreoffice.ts | 30 +- packages/doc-engine/src/pandoc.ts | 4 +- packages/doc-engine/src/pdfcpu.ts | 4 +- packages/doc-engine/src/qpdf.ts | 7 +- packages/media-engine/src/ffmpeg.ts | 12 +- packages/media-engine/src/ffprobe.ts | 4 +- packages/shared/src/index.ts | 1 + packages/shared/src/subprocess-limit.ts | 26 + .../integration/platform/ai-job-quota.test.ts | 79 ++ .../platform/mfa-endpoints.test.ts | 30 + tests/integration/platform/saml-cache.test.ts | 46 + tests/unit/api/ai-quota.test.ts | 17 + tests/unit/api/errors-friendly.test.ts | 36 +- .../api/object-storage-workspace-cap.test.ts | 45 + .../security/security-svg-sanitize.test.ts | 53 + tests/unit/shared/subprocess-limit.test.ts | 43 + 33 files changed, 1747 insertions(+), 57 deletions(-) create mode 100644 apps/api/drizzle/0006_majestic_sinister_six.sql create mode 100644 apps/api/drizzle/meta/0006_snapshot.json create mode 100644 apps/api/src/lib/ai-quota.ts create mode 100644 apps/api/src/lib/saml-cache.ts create mode 100644 packages/shared/src/subprocess-limit.ts create mode 100644 tests/integration/platform/ai-job-quota.test.ts create mode 100644 tests/integration/platform/saml-cache.test.ts create mode 100644 tests/unit/api/ai-quota.test.ts create mode 100644 tests/unit/api/object-storage-workspace-cap.test.ts create mode 100644 tests/unit/shared/subprocess-limit.test.ts diff --git a/.env.example b/.env.example index 2a069483..37479a27 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,10 @@ MAX_UPLOAD_SIZE_MB=0 MAX_BATCH_SIZE=0 CONCURRENT_JOBS=0 MAX_MEGAPIXELS=0 +# Max single-file AI jobs one user may have in flight (AI pool is concurrency 1; 0 = unlimited) +MAX_AI_JOBS_PER_USER=5 +# Optional per-process memory cap (MB) for the native media/doc engines (0 = disabled; container limit is the primary backstop) +SUBPROCESS_MEMORY_LIMIT_MB=0 # Max frames processed by animated background removal (0 = unlimited) GIF_BG_MAX_FRAMES=150 @@ -28,7 +32,9 @@ MAX_WORKER_THREADS=0 PROCESSING_TIMEOUT_S=0 MAX_PIPELINE_STEPS=0 MAX_CANVAS_PIXELS=0 -MAX_SVG_SIZE_MB=0 +# SVG has no "auto" sizing: 0 here disables the pre-parse size cap entirely. +# Ship the code default (50 MB) so copying this file does not remove the guard. +MAX_SVG_SIZE_MB=50 MAX_LOGO_SIZE_KB=2048 MAX_SPLIT_GRID=100 MAX_PDF_PAGES=0 diff --git a/apps/api/drizzle/0006_majestic_sinister_six.sql b/apps/api/drizzle/0006_majestic_sinister_six.sql new file mode 100644 index 00000000..1043724c --- /dev/null +++ b/apps/api/drizzle/0006_majestic_sinister_six.sql @@ -0,0 +1 @@ +CREATE INDEX "api_keys_key_prefix_idx" ON "api_keys" USING btree ("key_prefix"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0006_snapshot.json b/apps/api/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000..f6cb33bb --- /dev/null +++ b/apps/api/drizzle/meta/0006_snapshot.json @@ -0,0 +1,992 @@ +{ + "id": "5634b76b-9b83-4a81-9307-bebc895cbf65", + "prevId": "9e790b1b-8a07-4885-b3d9-3d3f81d0d4e8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default API Key'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_prefix_idx": { + "name": "api_keys_key_prefix_idx", + "columns": [ + { + "expression": "key_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_log_created_at_idx": { + "name": "audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_id_idx": { + "name": "audit_log_actor_id_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_id": { + "name": "tool_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_refs": { + "name": "input_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_refs": { + "name": "output_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "bytes_in": { + "name": "bytes_in", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delete_after": { + "name": "delete_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "jobs_created_at_idx": { + "name": "jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_user_id_users_id_fk": { + "name": "jobs_user_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_permissions": { + "name": "tool_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legal_hold": { + "name": "legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_quota": { + "name": "storage_quota", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "retention_hours": { + "name": "retention_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_files": { + "name": "user_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id_key_pk": { + "name": "user_preferences_user_id_key_pk", + "columns": ["user_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legal_hold": { + "name": "legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "storage_used": { + "name": "storage_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_quota": { + "name": "storage_quota", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "recovery_codes_hash": { + "name": "recovery_codes_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": ["queued", "processing", "completed", "failed", "canceled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 0d2bce5e..3a20c6e8 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1782266352290, "tag": "0005_special_exodus", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1784729211461, + "tag": "0006_majestic_sinister_six", + "breakpoints": true } ] } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 8dc4b5d6..0b74529e 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -110,21 +110,28 @@ export const jobs = pgTable( ], ); -export const apiKeys = pgTable("api_keys", { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - keyHash: text("key_hash").notNull(), - keyPrefix: text("key_prefix"), - name: text("name").notNull().default("Default API Key"), - permissions: jsonb("permissions").$type(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .$defaultFn(() => new Date()), - lastUsedAt: timestamp("last_used_at", { withTimezone: true }), - expiresAt: timestamp("expires_at", { withTimezone: true }), -}); +export const apiKeys = pgTable( + "api_keys", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + keyHash: text("key_hash").notNull(), + keyPrefix: text("key_prefix"), + name: text("name").notNull().default("Default API Key"), + permissions: jsonb("permissions").$type(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .$defaultFn(() => new Date()), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + }, + // Prefix lookup runs on every API-key auth; without this index it is a full + // table scan. The prefix is a SHA-256 slice (not unique by construction), so + // a non-unique index is correct. + (table) => [index("api_keys_key_prefix_idx").on(table.keyPrefix)], +); export const pipelines = pgTable("pipelines", { id: text("id").primaryKey(), diff --git a/apps/api/src/jobs/enqueue.ts b/apps/api/src/jobs/enqueue.ts index 5ac2a58f..8c5efc4d 100644 --- a/apps/api/src/jobs/enqueue.ts +++ b/apps/api/src/jobs/enqueue.ts @@ -10,6 +10,7 @@ import { FlowProducer, type Job, QueueEvents } from "bullmq"; import { eq } from "drizzle-orm"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; +import { assertAiJobQuota } from "../lib/ai-quota.js"; import { createBullMQConnection } from "./connection.js"; import { getQueue } from "./queues.js"; import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js"; @@ -114,6 +115,13 @@ export function injectTraceContext(data: ToolJobData): void { * Returns the BullMQ Job instance. */ export async function enqueueToolJob(data: ToolJobData): Promise> { + // Per-user concurrency cap for single-file AI jobs (kind "ai-tool"). Checked + // before the row insert so a rejected request leaves no job behind. Batch and + // pipeline AI use other kinds and are intentionally not capped here. + if (data.kind === "ai-tool") { + await assertAiJobQuota(data.userId); + } + // Insert the durable DB row first (crash-safe: row exists even if // Redis add fails and the job is retried on next boot). // When dbSettings is provided, persist the redacted version instead of diff --git a/apps/api/src/lib/ai-quota.ts b/apps/api/src/lib/ai-quota.ts new file mode 100644 index 00000000..3789da8c --- /dev/null +++ b/apps/api/src/lib/ai-quota.ts @@ -0,0 +1,51 @@ +/** + * Per-user concurrent AI-job cap. + * + * The AI BullMQ pool runs at concurrency 1, so a single user who enqueues many + * single-file AI jobs (each a heavy Python model load) monopolizes the worker + * and starves everyone else. This bounds a user's in-flight AI jobs. Batch and + * pipeline AI use other job kinds and are deliberately not counted here: they + * are already bounded by MAX_BATCH_SIZE and the pipeline step count, and capping + * them would break legitimate multi-file AI runs. + */ +import { and, count, eq, inArray } from "drizzle-orm"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; + +/** Pure threshold check exported for unit testing. cap <= 0 disables the limit. */ +export function isOverAiJobCap(inFlight: number, cap: number): boolean { + return cap > 0 && inFlight >= cap; +} + +/** Count a user's queued + processing single-file AI jobs (job kind "ai-tool"). */ +export async function countInFlightAiJobs(userId: string): Promise { + const rows = await db + .select({ c: count() }) + .from(schema.jobs) + .where( + and( + eq(schema.jobs.userId, userId), + eq(schema.jobs.type, "ai-tool"), + inArray(schema.jobs.status, ["queued", "processing"]), + ), + ); + return rows[0]?.c ?? 0; +} + +/** + * Throw a 429-tagged error when the user already has MAX_AI_JOBS_PER_USER + * single-file AI jobs in flight. No-op when the cap is disabled (<= 0) or the + * request is unauthenticated (no user to key on). + */ +export async function assertAiJobQuota(userId: string | null | undefined): Promise { + const cap = env.MAX_AI_JOBS_PER_USER; + if (cap <= 0 || !userId) return; + const inFlight = await countInFlightAiJobs(userId); + if (isOverAiJobCap(inFlight, cap)) { + const error = new Error( + "Too many concurrent AI jobs. Please wait for existing jobs to finish.", + ); + (error as Error & { statusCode: number }).statusCode = 429; + throw error; + } +} diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 1b50e39a..328d878e 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -59,6 +59,16 @@ const envSchema = z MAX_SPLIT_GRID: z.coerce.number().default(100), MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000), MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10), + // Max single-file AI jobs one user may have queued/processing at once. The + // AI pool runs at concurrency 1, so this stops one user from starving it. + // 0 = unlimited. Batch/pipeline AI are bounded separately and not counted. + MAX_AI_JOBS_PER_USER: z.coerce.number().default(5), + // Optional per-process address-space cap (MB) for the native media/doc + // engines (ffmpeg, ghostscript, qpdf, libreoffice, pandoc, pdfcpu). 0 = + // disabled (the container memory limit is the primary backstop). Not applied + // to the AI sidecar (torch/CUDA reserve huge virtual space). See + // packages/shared/src/subprocess-limit.ts. + SUBPROCESS_MEMORY_LIMIT_MB: z.coerce.number().default(0), MAX_PDF_PAGES: z.coerce.number().default(0), MAX_VIDEO_DURATION_S: z.coerce.number().default(0), MAX_AUDIO_DURATION_S: z.coerce.number().default(0), diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index 4305565d..025beac9 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -11,7 +11,10 @@ export function formatZodErrors(issues: ZodIssue[]): string { * leaking server directory structure to API consumers. */ export function stripInternalPaths(message: string): string { - return message.replace(/\/(tmp|data|app|opt|home|workspace)\b[^\s'")}]*/g, "[internal]"); + return message.replace( + /\/(?:tmp|data|app|opt|home|workspace)\b[^\s'")}]*|[A-Za-z]:\\[^\s'")}]*/g, + "[internal]", + ); } // Matching control characters is the entire point of these patterns (we strip @@ -36,10 +39,16 @@ export function stripControlChars(message: string): string { /** * Unambiguous markers of a raw external-tool failure dump: the * `ffmpeg/ffprobe exited N:` prefix that media-engine throws, a Python - * traceback, or a crash. These are matched precisely (not by content - * keywords like "pixel format" or "conversion failed", which can appear in - * legitimate validation messages) -- longer or multi-line raw dumps are - * caught separately by the length/line-count check below. + * traceback, or a crash. Matched precisely (not by content keywords like + * "pixel format" or "conversion failed", which appear in legitimate validation + * messages); longer or multi-line raw dumps are caught by the length/line-count + * check below. + * + * Doc-engine tools (qpdf, gs, pdfcpu, pandoc, LibreOffice) are deliberately NOT + * matched here. Their short stderr is already path-scrubbed and often carries + * the actionable reason (qpdf surfaces "invalid password" this way), so + * collapsing it to the generic sentence would hide errors users need. The + * genuinely verbose dumps are still caught by the length/line-count guard. */ const RAW_TOOL_FAILURE = /ff(?:mpeg|probe) exited \d|traceback \(most recent call|segmentation fault|core dumped/i; diff --git a/apps/api/src/lib/object-storage.ts b/apps/api/src/lib/object-storage.ts index 0e9ea16e..c4f0fb21 100644 --- a/apps/api/src/lib/object-storage.ts +++ b/apps/api/src/lib/object-storage.ts @@ -79,6 +79,69 @@ export function isBelowCapacity(freeBytes: number): boolean { return freeBytes / 1024 ** 3 < CAPACITY_CRITICAL_GB; } +// ── Aggregate workspace-size cap (MAX_WORKSPACE_SIZE_GB) ────────── +// A free-space floor alone does not stop SnapOtter from filling a large shared +// volume before that floor trips. The configured cap bounds uploads/ + outputs/ +// regardless of how much room the underlying disk has. The total is cached +// briefly so a busy instance does not re-walk the tree on every write. + +let workspaceSizeCache: { bytes: number; at: number } | null = null; +const WORKSPACE_SIZE_CACHE_MS = 30_000; + +/** + * Sum the sizes of every file under `/uploads` and `/outputs`. Keys + * are always `//` (two levels), so a shallow job-dir + * walk suffices. Missing or unreadable paths contribute 0. + */ +export async function computeWorkspaceUsedBytes(root: string): Promise { + let total = 0; + for (const prefix of ["uploads", "outputs"] as const) { + let jobDirs: string[]; + try { + jobDirs = await readdir(join(root, prefix)); + } catch { + continue; + } + for (const jobDir of jobDirs) { + const dir = join(root, prefix, jobDir); + let names: string[]; + try { + names = await readdir(dir); + } catch { + continue; + } + for (const name of names) { + const s = await stat(join(dir, name)).catch(() => null); + if (s?.isFile()) total += s.size; + } + } + } + return total; +} + +/** Pure threshold check exported for unit testing. maxGb <= 0 disables the cap. */ +export function isOverWorkspaceCap(usedBytes: number, maxGb: number): boolean { + return maxGb > 0 && usedBytes / 1024 ** 3 > maxGb; +} + +async function assertWorkspaceSizeCap(root: string): Promise { + const maxGb = env.MAX_WORKSPACE_SIZE_GB; + if (maxGb <= 0) return; + const now = Date.now(); + let used: number; + if (workspaceSizeCache && now - workspaceSizeCache.at < WORKSPACE_SIZE_CACHE_MS) { + used = workspaceSizeCache.bytes; + } else { + used = await computeWorkspaceUsedBytes(root); + workspaceSizeCache = { bytes: used, at: now }; + } + if (isOverWorkspaceCap(used, maxGb)) { + const error = new Error("Workspace storage limit reached; try again shortly"); + (error as Error & { statusCode: number }).statusCode = 503; + throw error; + } +} + /** * Asserts that the local storage volume has enough free space. * Called by putObject / putObjectStream for the local backend only. @@ -87,6 +150,8 @@ export function isBelowCapacity(freeBytes: number): boolean { export async function assertLocalCapacity(): Promise { const root = env.WORKSPACE_PATH; if (!existsSync(root)) return; + // Aggregate size cap first: it applies even where statfs is unavailable. + await assertWorkspaceSizeCap(root); let fsStats: Awaited>; try { fsStats = await statfs(root); diff --git a/apps/api/src/lib/saml-cache.ts b/apps/api/src/lib/saml-cache.ts new file mode 100644 index 00000000..6ffe282a --- /dev/null +++ b/apps/api/src/lib/saml-cache.ts @@ -0,0 +1,39 @@ +import type { CacheItem, CacheProvider } from "@node-saml/node-saml"; +import { sharedRedis } from "../jobs/connection.js"; + +const KEY_PREFIX = "saml:req:"; + +/** + * Matches node-saml's default requestIdExpirationPeriodMs (8h). The Redis TTL + * must be at least as long, or a slow-but-legitimate SP-initiated login would + * find its request id already gone and fail InResponseTo validation. + */ +export const SAML_REQUEST_TTL_SECONDS = 8 * 60 * 60; + +/** + * Redis-backed CacheProvider for node-saml InResponseTo validation. + * + * `saveAsync` uses SET NX, so a request id is stored exactly once. node-saml + * removes the id when it consumes a valid SAML Response, so a replayed Response + * whose InResponseTo was already consumed finds nothing and is rejected. Backed + * by the shared Redis so the id written during the login redirect is visible to + * the callback even though each handler builds a fresh SAML instance. + */ +export function makeRedisSamlCacheProvider( + ttlSeconds: number = SAML_REQUEST_TTL_SECONDS, +): CacheProvider { + return { + async saveAsync(key: string, value: string): Promise { + const ok = await sharedRedis().set(`${KEY_PREFIX}${key}`, value, "EX", ttlSeconds, "NX"); + return ok === "OK" ? { value, createdAt: Date.now() } : null; + }, + async getAsync(key: string): Promise { + return sharedRedis().get(`${KEY_PREFIX}${key}`); + }, + async removeAsync(key: string | null): Promise { + if (key === null) return null; + const removed = await sharedRedis().del(`${KEY_PREFIX}${key}`); + return removed > 0 ? key : null; + }, + }; +} diff --git a/apps/api/src/lib/svg-sanitize.ts b/apps/api/src/lib/svg-sanitize.ts index 1c439b18..14f21d00 100644 --- a/apps/api/src/lib/svg-sanitize.ts +++ b/apps/api/src/lib/svg-sanitize.ts @@ -73,7 +73,22 @@ export function sanitizeSvg(buffer: Buffer): Buffer { // close tag -- repeating until stable so nested or overlapping tags cannot // survive a single pass (foreignObject/iframe/embed can embed HTML; set/animate // can inject attributes/URIs at runtime). - for (const tag of ["script", "foreignObject", "iframe", "embed", "set", "animate"]) { + // animateTransform/animateMotion/animateColor are distinct element names (a + // word boundary stops the "animate" pattern from matching them), and + // is the SVG-Tiny event-handler element; all can carry runtime script/URIs. + for (const tag of [ + "script", + "foreignObject", + "iframe", + "embed", + "set", + "animate", + "animateTransform", + "animateMotion", + "animateColor", + "handler", + "mpath", + ]) { svg = stripUntilStable( svg, new RegExp(`<${tag}\\b[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), @@ -97,13 +112,17 @@ export function sanitizeSvg(buffer: Buffer): Buffer { svg = svg.replace(/]*href\s*=\s*["']file:[^"']*["'][^>]*\/?>/gi, ""); svg = svg.replace(/]*href\s*=\s*["']data:[^"']*["'][^>]*\/?>/gi, ""); - // ── Block dangerous URI schemes in href attributes ── - svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,'); - svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,'); - svg = svg.replace(/href\s*=\s*["']javascript:/gi, 'href="data:,'); - // Block ALL data: URIs in href (not just data:text/html) - svg = svg.replace(/href\s*=\s*["']data:/gi, 'href="data:,'); - svg = svg.replace(/href\s*=\s*["']file:/gi, 'href="data:,'); + // ── Block dangerous URI schemes in href / xlink:href ── + // One pass covers javascript:, data:, file:, and http(s): on both `href` and + // `xlink:href`, tolerating unquoted values and leading whitespace before the + // scheme (browsers trim it) which the older per-scheme patterns missed. The + // capture preserves the `xlink:` prefix so the neutralized attribute stays + // well-formed. Durable follow-up: replace this regex sanitizer with an XML + // parse + allowlist, which is the real fix for regex whack-a-mole. + svg = svg.replace( + /((?:xlink:)?href)\s*=\s*(?:["']\s*)?(?:javascript|data|file|https?):/gi, + '$1="data:,', + ); // ── Block dangerous schemes in url() values ── svg = svg.replace(/url\s*\(\s*["']?https?:\/\//gi, 'url("data:,'); diff --git a/apps/api/src/plugins/mfa.ts b/apps/api/src/plugins/mfa.ts index c86d588f..613ea4a0 100644 --- a/apps/api/src/plugins/mfa.ts +++ b/apps/api/src/plugins/mfa.ts @@ -21,6 +21,10 @@ import { createSessionToken, requireAuth } from "./auth.js"; const RECOVERY_CODE_COUNT = 8; const SESSION_DURATION_MS = env.SESSION_DURATION_HOURS * 60 * 60 * 1000; +/** Wrong-code budget before a pending MFA challenge is burned (forces re-login). */ +const MFA_MAX_FAILED_ATTEMPTS = 5; +/** Mirrors the 300s TTL set on `mfa:` at login (auth/oidc/saml). */ +const MFA_CHALLENGE_TTL_SECONDS = 300; // ── Zod schemas ─────────────────────────────────────────────────── @@ -329,6 +333,15 @@ export async function registerMfa(app: FastifyInstance): Promise { } if (!verified) { + // Burn the challenge after a few wrong codes so an attacker who already + // holds valid credentials cannot grind TOTP guesses within the challenge + // window. The counter shares the challenge's lifetime. + const attemptsKey = `mfa:attempts:${mfaToken}`; + const attempts = await redis.incr(attemptsKey); + if (attempts === 1) await redis.expire(attemptsKey, MFA_CHALLENGE_TTL_SECONDS); + if (attempts >= MFA_MAX_FAILED_ATTEMPTS) { + await redis.del(`mfa:${mfaToken}`, attemptsKey); + } await audit("MFA_VERIFY_FAILED", { userId, username: dbUser.username }); return reply.status(401).send({ error: "Invalid TOTP or recovery code", @@ -336,8 +349,8 @@ export async function registerMfa(app: FastifyInstance): Promise { }); } - // Delete the challenge token - await redis.del(`mfa:${mfaToken}`); + // Delete the challenge token and any failed-attempt counter + await redis.del(`mfa:${mfaToken}`, `mfa:attempts:${mfaToken}`); // Create session (same as normal login completion) const token = createSessionToken(); diff --git a/apps/api/src/plugins/saml.ts b/apps/api/src/plugins/saml.ts index d3000b43..8fc5a726 100644 --- a/apps/api/src/plugins/saml.ts +++ b/apps/api/src/plugins/saml.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { parse as parseQs } from "node:querystring"; import type {} from "@fastify/cookie"; -import { SAML } from "@node-saml/node-saml"; +import { SAML, ValidateInResponseTo } from "@node-saml/node-saml"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { env } from "../config.js"; @@ -14,10 +14,16 @@ import { sanitizeUsername, } from "../lib/external-auth-resolver.js"; import { authAttempts } from "../lib/metrics.js"; +import { makeRedisSamlCacheProvider } from "../lib/saml-cache.js"; import { createSessionToken } from "./auth.js"; // -- SAML instance factory ---------------------------------------------------- +// One shared, Redis-backed cache so a request id written during the login +// redirect is visible to the callback (each handler builds a fresh SAML +// instance). This is what makes InResponseTo replay protection work. +const samlCacheProvider = makeRedisSamlCacheProvider(); + function getSamlInstance(): SAML { return new SAML({ callbackUrl: env.SAML_CALLBACK_URL || `${env.EXTERNAL_URL}/api/auth/saml/callback`, @@ -26,6 +32,12 @@ function getSamlInstance(): SAML { idpCert: env.SAML_IDP_CERTIFICATE, wantAuthnResponseSigned: true, wantAssertionsSigned: true, + // Bind each SAML Response to the AuthnRequest we issued and consume that id, + // so a captured, still-signed assertion cannot be replayed to mint a second + // session. "ifPresent" (not "always") keeps IdP-initiated SSO working, since + // those responses carry no InResponseTo. + validateInResponseTo: ValidateInResponseTo.ifPresent, + cacheProvider: samlCacheProvider, }); } diff --git a/docker/docker-compose-gpu.yml b/docker/docker-compose-gpu.yml index 6a2268db..df4572a7 100644 --- a/docker/docker-compose-gpu.yml +++ b/docker/docker-compose-gpu.yml @@ -128,7 +128,22 @@ services: volumes: - SnapOtter-pgdata:/var/lib/postgresql/data restart: unless-stopped + # --- Security hardening --- mem_limit: 1g + memswap_limit: 1g + cpus: 2 + pids_limit: 256 + # The official image starts as root and su-execs down to the postgres user, + # so setuid/setgid + chown caps are required. no-new-privileges and a + # read-only rootfs are omitted for the same privilege-drop reason as the app. + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter} -d ${POSTGRES_DB:-snapotter}"] interval: 10s @@ -148,7 +163,22 @@ services: volumes: - SnapOtter-redisdata:/data restart: unless-stopped + # --- Security hardening --- mem_limit: 1g + memswap_limit: 1g + cpus: 2 + pids_limit: 256 + # The official image starts as root and gosu-drops to the redis user, so + # setuid/setgid + chown caps are required; no-new-privileges and a read-only + # rootfs are omitted for the same privilege-drop reason as the app. + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-snapotter}", "--no-auth-warning", "ping"] interval: 10s diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7aefe39b..1327b6fe 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -120,7 +120,22 @@ services: volumes: - SnapOtter-pgdata:/var/lib/postgresql/data restart: unless-stopped + # --- Security hardening --- mem_limit: 1g + memswap_limit: 1g + cpus: 2 + pids_limit: 256 + # The official image starts as root and su-execs down to the postgres user, + # so setuid/setgid + chown caps are required. no-new-privileges and a + # read-only rootfs are omitted for the same privilege-drop reason as the app. + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snapotter} -d ${POSTGRES_DB:-snapotter}"] interval: 10s @@ -140,7 +155,22 @@ services: volumes: - SnapOtter-redisdata:/data restart: unless-stopped + # --- Security hardening --- mem_limit: 1g + memswap_limit: 1g + cpus: 2 + pids_limit: 256 + # The official image starts as root and gosu-drops to the redis user, so + # setuid/setgid + chown caps are required; no-new-privileges and a read-only + # rootfs are omitted for the same privilege-drop reason as the app. + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-snapotter}", "--no-auth-warning", "ping"] interval: 10s diff --git a/packages/doc-engine/src/ghostscript.ts b/packages/doc-engine/src/ghostscript.ts index ac4e243a..e19f8d86 100644 --- a/packages/doc-engine/src/ghostscript.ts +++ b/packages/doc-engine/src/ghostscript.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { wrapWithMemoryLimit } from "@snapotter/shared"; import { resolveGs } from "./binaries.js"; export type PdfCompressionPreset = "screen" | "ebook" | "printer"; @@ -8,7 +9,8 @@ function runGs(args: string[], timeoutMs = 120_000): Promise { const bin = resolveGs(); if (!bin) throw new Error("gs binary not found (set GS_PATH or install ghostscript)"); return new Promise((resolvePromise, reject) => { - const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] }); + const [limBin, limArgs] = wrapWithMemoryLimit(bin, args); + const child = spawn(limBin, limArgs, { stdio: ["ignore", "ignore", "pipe"] }); let err = ""; let settled = false; const timer = setTimeout(() => { diff --git a/packages/doc-engine/src/libreoffice.ts b/packages/doc-engine/src/libreoffice.ts index 3fb3eddc..7cbf5ebe 100644 --- a/packages/doc-engine/src/libreoffice.ts +++ b/packages/doc-engine/src/libreoffice.ts @@ -4,6 +4,7 @@ import { readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, extname, join } from "node:path"; import { pathToFileURL } from "node:url"; +import { wrapWithMemoryLimit } from "@snapotter/shared"; import { resolveSoffice } from "./binaries.js"; export interface ConvertOptions { @@ -48,22 +49,19 @@ export async function convertDocument( const { ext, convertTo } = parseConvertTarget(target); try { await new Promise((resolvePromise, reject) => { - const child = spawn( - bin, - [ - `-env:UserInstallation=${pathToFileURL(profileDir).href}`, - "--headless", - "--norestore", - "--nolockcheck", - "--nodefault", - "--convert-to", - convertTo, - "--outdir", - outDir, - inputPath, - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); + const [limBin, limArgs] = wrapWithMemoryLimit(bin, [ + `-env:UserInstallation=${pathToFileURL(profileDir).href}`, + "--headless", + "--norestore", + "--nolockcheck", + "--nodefault", + "--convert-to", + convertTo, + "--outdir", + outDir, + inputPath, + ]); + const child = spawn(limBin, limArgs, { stdio: ["ignore", "pipe", "pipe"] }); let err = ""; let settled = false; const timer = setTimeout(() => { diff --git a/packages/doc-engine/src/pandoc.ts b/packages/doc-engine/src/pandoc.ts index 44e9b450..15852a23 100644 --- a/packages/doc-engine/src/pandoc.ts +++ b/packages/doc-engine/src/pandoc.ts @@ -1,4 +1,5 @@ import { spawn, spawnSync } from "node:child_process"; +import { wrapWithMemoryLimit } from "@snapotter/shared"; /** Resolve the pandoc binary, honoring PANDOC_PATH for parity with the other doc-engine wrappers. */ function pandocBin(): string { @@ -69,7 +70,8 @@ export function runPandoc( const timeoutMs = opts.timeoutMs ?? 120_000; const args = buildPandocArgs(inPath, outPath, opts); return new Promise((resolvePromise, reject) => { - const child = spawn(pandocBin(), args, { stdio: ["ignore", "pipe", "pipe"] }); + const [limBin, limArgs] = wrapWithMemoryLimit(pandocBin(), args); + const child = spawn(limBin, limArgs, { stdio: ["ignore", "pipe", "pipe"] }); let err = ""; let settled = false; const timer = setTimeout(() => { diff --git a/packages/doc-engine/src/pdfcpu.ts b/packages/doc-engine/src/pdfcpu.ts index ec7f20ab..e131ab5a 100644 --- a/packages/doc-engine/src/pdfcpu.ts +++ b/packages/doc-engine/src/pdfcpu.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { wrapWithMemoryLimit } from "@snapotter/shared"; import { resolvePdfcpu } from "./binaries.js"; /** @@ -10,7 +11,8 @@ function runPdfcpu(args: string[], timeoutMs = 60_000): Promise { const bin = resolvePdfcpu(); if (!bin) throw new Error("pdfcpu binary not found (set PDFCPU_PATH or install pdfcpu)"); return new Promise((resolvePromise, reject) => { - const child = spawn(bin, ["-c", "disable", ...args], { + const [limBin, limArgs] = wrapWithMemoryLimit(bin, ["-c", "disable", ...args]); + const child = spawn(limBin, limArgs, { stdio: ["ignore", "pipe", "pipe"], }); let out = ""; diff --git a/packages/doc-engine/src/qpdf.ts b/packages/doc-engine/src/qpdf.ts index 7bd0417f..10b6c14d 100644 --- a/packages/doc-engine/src/qpdf.ts +++ b/packages/doc-engine/src/qpdf.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { wrapWithMemoryLimit } from "@snapotter/shared"; import { resolveQpdf } from "./binaries.js"; /** @internal Shared qpdf CLI runner for doc-engine modules; not part of the public package API. */ @@ -6,7 +7,8 @@ export function runQpdf(args: string[], timeoutMs = 30_000): Promise { const bin = resolveQpdf(); if (!bin) throw new Error("qpdf binary not found (set QPDF_PATH or install qpdf)"); return new Promise((resolvePromise, reject) => { - const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); + const [limBin, limArgs] = wrapWithMemoryLimit(bin, args); + const child = spawn(limBin, limArgs, { stdio: ["ignore", "pipe", "pipe"] }); let out = ""; let err = ""; let settled = false; @@ -63,7 +65,8 @@ export async function qpdfRequiresPassword(filePath: string): Promise { const bin = resolveQpdf(); if (!bin) throw new Error("qpdf binary not found (set QPDF_PATH or install qpdf)"); return new Promise((resolvePromise, reject) => { - const child = spawn(bin, ["--requires-password", filePath], { + const [limBin, limArgs] = wrapWithMemoryLimit(bin, ["--requires-password", filePath]); + const child = spawn(limBin, limArgs, { stdio: ["ignore", "ignore", "pipe"], }); let settled = false; diff --git a/packages/media-engine/src/ffmpeg.ts b/packages/media-engine/src/ffmpeg.ts index 889d2c3d..d30834da 100644 --- a/packages/media-engine/src/ffmpeg.ts +++ b/packages/media-engine/src/ffmpeg.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { markToolInputError, SafeError } from "@snapotter/shared"; +import { markToolInputError, SafeError, wrapWithMemoryLimit } from "@snapotter/shared"; import { resolveFfmpeg } from "./binaries.js"; import { type FfmpegProgress, parseProgressBlock } from "./progress.js"; @@ -39,7 +39,15 @@ export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Pr const bin = resolveFfmpeg(); if (!bin) throw new Error("ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)"); return new Promise((resolvePromise, reject) => { - const child = spawn(bin, ["-hide_banner", "-nostdin", "-y", ...args, "-progress", "pipe:1"], { + const [limBin, limArgs] = wrapWithMemoryLimit(bin, [ + "-hide_banner", + "-nostdin", + "-y", + ...args, + "-progress", + "pipe:1", + ]); + const child = spawn(limBin, limArgs, { stdio: ["ignore", "pipe", "pipe"], }); let stderrTail = ""; diff --git a/packages/media-engine/src/ffprobe.ts b/packages/media-engine/src/ffprobe.ts index a8e23ffd..895c4b44 100644 --- a/packages/media-engine/src/ffprobe.ts +++ b/packages/media-engine/src/ffprobe.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { wrapWithMemoryLimit } from "@snapotter/shared"; import { resolveFfprobe } from "./binaries.js"; import { markIfInputError } from "./ffmpeg.js"; @@ -42,7 +43,8 @@ export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Pro ]; const timeoutMs = opts.timeoutMs ?? 15_000; const stdout = await new Promise((resolvePromise, reject) => { - const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); + const [limBin, limArgs] = wrapWithMemoryLimit(bin, args); + const child = spawn(limBin, limArgs, { stdio: ["ignore", "pipe", "pipe"] }); let out = ""; let err = ""; let settled = false; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7669c09b..b20c5cfa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -14,5 +14,6 @@ export * from "./permissions.js"; export * from "./pipeline-templates.js"; export * from "./search/format-aliases.js"; export * from "./section.js"; +export * from "./subprocess-limit.js"; export * from "./tool-errors.js"; export * from "./types.js"; diff --git a/packages/shared/src/subprocess-limit.ts b/packages/shared/src/subprocess-limit.ts new file mode 100644 index 00000000..2d285d9f --- /dev/null +++ b/packages/shared/src/subprocess-limit.ts @@ -0,0 +1,26 @@ +/** + * Optional per-subprocess address-space cap (RLIMIT_AS) for the native media and + * document engines. + * + * When SUBPROCESS_MEMORY_LIMIT_MB is a positive integer, the command runs under + * /bin/sh, which sets `ulimit -v` and then `exec`s the real binary with its exact + * argv. `exec "$@"` does not re-parse the arguments through the shell, so this + * stays injection-safe. A decompression bomb or runaway filter graph is then + * killed at that ceiling instead of driving the whole container to the cgroup + * OOM-killer (which would take every in-flight job down with it). + * + * Disabled by default (unset or 0): the container memory limit remains the + * primary backstop, and `ulimit -v` is a blunt instrument (it caps virtual + * address space, not RSS). `|| true` makes it a no-op where `ulimit -v` is + * unsupported, e.g. macOS. + * + * Deliberately NOT applied to the Python AI sidecar: ML frameworks (torch, CUDA) + * reserve very large virtual address space without touching it, so an RLIMIT_AS + * cap would break legitimate model loads. Those rely on the container limit. + */ +export function wrapWithMemoryLimit(bin: string, args: string[]): [string, string[]] { + const mb = Number.parseInt(process.env.SUBPROCESS_MEMORY_LIMIT_MB ?? "", 10); + if (!Number.isFinite(mb) || mb <= 0) return [bin, args]; + const script = 'ulimit -v "$1" 2>/dev/null || true; shift; exec "$@"'; + return ["/bin/sh", ["-c", script, "sh", String(mb * 1024), bin, ...args]]; +} diff --git a/tests/integration/platform/ai-job-quota.test.ts b/tests/integration/platform/ai-job-quota.test.ts new file mode 100644 index 00000000..6ca66a7a --- /dev/null +++ b/tests/integration/platform/ai-job-quota.test.ts @@ -0,0 +1,79 @@ +import { and, eq } from "drizzle-orm"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +const { buildTestApp, loginAsAdmin } = await import("../test-server.js"); +const { db, schema } = await import("../../../apps/api/src/db/index.js"); +const { countInFlightAiJobs } = await import("../../../apps/api/src/lib/ai-quota.js"); +const { enqueueToolJob } = await import("../../../apps/api/src/jobs/enqueue.js"); +const { env } = await import("../../../apps/api/src/config.js"); + +import type { TestApp } from "../test-server.js"; + +let testApp: TestApp; +let adminId: string; + +async function seedAiJob(userId: string, status: "queued" | "processing", kind: string) { + const id = `test-aijob-${Math.random().toString(36).slice(2)}`; + await db.insert(schema.jobs).values({ + id, + userId, + toolId: "colorize", + pool: "ai", + type: kind, + status, + inputRefs: [`uploads/${id}/x.png`], + settings: {}, + }); + return id; +} + +beforeAll(async () => { + testApp = await buildTestApp(); + await loginAsAdmin(testApp.app); + const [admin] = await db.select().from(schema.users).where(eq(schema.users.username, "admin")); + adminId = admin.id; +}, 30_000); + +afterEach(async () => { + await db.delete(schema.jobs).where(and(eq(schema.jobs.userId, adminId))); +}); + +describe("countInFlightAiJobs", () => { + it("counts only queued/processing ai-tool jobs, ignoring other kinds and terminal states", async () => { + await seedAiJob(adminId, "queued", "ai-tool"); + await seedAiJob(adminId, "processing", "ai-tool"); + await seedAiJob(adminId, "queued", "batch-child"); // different kind: excluded + const done = await seedAiJob(adminId, "queued", "ai-tool"); + await db.update(schema.jobs).set({ status: "completed" }).where(eq(schema.jobs.id, done)); + + expect(await countInFlightAiJobs(adminId)).toBe(2); + }); +}); + +describe("enqueueToolJob AI quota enforcement", () => { + it("rejects a new ai-tool job with 429 once the user is at the cap", async () => { + const cap = env.MAX_AI_JOBS_PER_USER; + expect(cap).toBeGreaterThan(0); + for (let i = 0; i < cap; i++) await seedAiJob(adminId, "queued", "ai-tool"); + + await expect( + enqueueToolJob({ + jobId: "test-over-cap-job", + toolId: "colorize", + userId: adminId, + pool: "ai", + inputRefs: ["uploads/test-over-cap-job/x.png"], + filename: "x.png", + settings: {}, + kind: "ai-tool", + }), + ).rejects.toMatchObject({ statusCode: 429 }); + + // The rejected job left no row behind. + const [row] = await db + .select() + .from(schema.jobs) + .where(eq(schema.jobs.id, "test-over-cap-job")); + expect(row).toBeUndefined(); + }); +}); diff --git a/tests/integration/platform/mfa-endpoints.test.ts b/tests/integration/platform/mfa-endpoints.test.ts index ddda4691..6282021d 100644 --- a/tests/integration/platform/mfa-endpoints.test.ts +++ b/tests/integration/platform/mfa-endpoints.test.ts @@ -377,6 +377,36 @@ describe("MFA login flow", () => { expect(body.user.username).toBe("admin"); expect(body.expiresAt).toBeDefined(); }); + + it("burns the challenge after repeated wrong codes so the correct code no longer works", async () => { + const loginRes = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "admin", password: "Adminpass1" }, + }); + const { mfaToken } = JSON.parse(loginRes.body); + + // Exhaust the wrong-code budget. Each wrong attempt is a 401. + for (let i = 0; i < 5; i++) { + const bad = await testApp.app.inject({ + method: "POST", + url: "/api/auth/mfa/complete", + payload: { mfaToken, code: "000000" }, + }); + expect(bad.statusCode).toBe(401); + } + + // The challenge is now burned: even the correct TOTP is rejected as expired, + // forcing the attacker back through the login (and its rate limit). + const code = generateTotpCode(totpUri); + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/mfa/complete", + payload: { mfaToken, code }, + }); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).code).toBe("MFA_EXPIRED"); + }); }); async function setMfaPolicy(value: "optional" | "admins_only" | "required"): Promise { diff --git a/tests/integration/platform/saml-cache.test.ts b/tests/integration/platform/saml-cache.test.ts new file mode 100644 index 00000000..223fc5d8 --- /dev/null +++ b/tests/integration/platform/saml-cache.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +const { makeRedisSamlCacheProvider } = await import("../../../apps/api/src/lib/saml-cache.js"); +const { sharedRedis } = await import("../../../apps/api/src/jobs/connection.js"); + +// Verifies the InResponseTo replay-prevention primitive behind SAML hardening: +// a request ID can be stored once, is rejected on a duplicate save (the replay +// signal node-saml keys off), and disappears after it is consumed. +describe("SAML Redis cache provider (InResponseTo replay protection)", () => { + it("stores a request id once, rejects a duplicate save, and consumes on remove", async () => { + const provider = makeRedisSamlCacheProvider(); + const key = `test-${Math.random().toString(36).slice(2)}`; + + const first = await provider.saveAsync(key, key); + expect(first).not.toBeNull(); + expect(first?.value).toBe(key); + + // A replayed response reuses the same InResponseTo id: the save must fail. + const duplicate = await provider.saveAsync(key, key); + expect(duplicate).toBeNull(); + + expect(await provider.getAsync(key)).toBe(key); + + // Consuming (as node-saml does on a valid first use) removes it, so a later + // replay finds nothing and is rejected. + expect(await provider.removeAsync(key)).toBe(key); + expect(await provider.getAsync(key)).toBeNull(); + expect(await provider.removeAsync(key)).toBeNull(); + }); + + it("returns null from removeAsync when given a null key", async () => { + const provider = makeRedisSamlCacheProvider(); + expect(await provider.removeAsync(null)).toBeNull(); + }); + + it("honors the TTL so stale request ids self-expire", async () => { + const provider = makeRedisSamlCacheProvider(1); // 1 second + const key = `test-ttl-${Math.random().toString(36).slice(2)}`; + await provider.saveAsync(key, key); + // Confirm the TTL was actually set on the namespaced key (not persisted). + const ttl = await sharedRedis().ttl(`saml:req:${key}`); + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(1); + await provider.removeAsync(key); + }); +}); diff --git a/tests/unit/api/ai-quota.test.ts b/tests/unit/api/ai-quota.test.ts new file mode 100644 index 00000000..f241c048 --- /dev/null +++ b/tests/unit/api/ai-quota.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { isOverAiJobCap } from "../../../apps/api/src/lib/ai-quota.js"; + +describe("isOverAiJobCap", () => { + it("is disabled (never over) when cap is 0", () => { + expect(isOverAiJobCap(1000, 0)).toBe(false); + }); + + it("is false while in-flight is below the cap", () => { + expect(isOverAiJobCap(4, 5)).toBe(false); + }); + + it("is true once in-flight reaches the cap", () => { + expect(isOverAiJobCap(5, 5)).toBe(true); + expect(isOverAiJobCap(6, 5)).toBe(true); + }); +}); diff --git a/tests/unit/api/errors-friendly.test.ts b/tests/unit/api/errors-friendly.test.ts index 944d8a86..2fc5b237 100644 --- a/tests/unit/api/errors-friendly.test.ts +++ b/tests/unit/api/errors-friendly.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { friendlyError, stripControlChars } from "../../../apps/api/src/lib/errors.js"; +import { + friendlyError, + stripControlChars, + stripInternalPaths, +} from "../../../apps/api/src/lib/errors.js"; const GENERIC = "Processing failed. The file may be in an unsupported or corrupted format."; @@ -14,6 +18,18 @@ describe("friendlyError", () => { expect(friendlyError("ffprobe exited 1: moov atom not found")).toBe(GENERIC); }); + it("preserves short doc-engine errors that carry the actionable reason", () => { + // qpdf/gs/pdfcpu stderr is already path-scrubbed and often IS the useful + // message (e.g. a wrong PDF password), so it must not collapse to generic. + // Only genuinely verbose dumps collapse, via the length/line-count guard. + expect(friendlyError("qpdf exited 2: invalid password")).toBe( + "qpdf exited 2: invalid password", + ); + expect(friendlyError("pdfcpu exited 1: validation error at object 5")).toBe( + "pdfcpu exited 1: validation error at object 5", + ); + }); + it("collapses python tracebacks", () => { expect(friendlyError("Traceback (most recent call last):\n File x\nValueError: boom")).toBe( GENERIC, @@ -83,3 +99,21 @@ describe("stripControlChars", () => { expect(stripControlChars("Café déjà vu")).toBe("Café déjà vu"); }); }); + +describe("stripInternalPaths", () => { + it("strips POSIX internal roots", () => { + expect(stripInternalPaths("wrote /tmp/workspace/out.mp4 ok")).toBe("wrote [internal] ok"); + expect(stripInternalPaths("model at /data/ai/models/whisper")).toBe("model at [internal]"); + }); + + it("strips Windows drive-letter paths (native Windows runs)", () => { + // Matches sentry-scrub's PATH_RE so client responses and Sentry agree. + expect(stripInternalPaths("failed reading C:\\Users\\snap\\secret.pdf")).toBe( + "failed reading [internal]", + ); + }); + + it("leaves messages with no path untouched", () => { + expect(stripInternalPaths("Region exceeds image bounds")).toBe("Region exceeds image bounds"); + }); +}); diff --git a/tests/unit/api/object-storage-workspace-cap.test.ts b/tests/unit/api/object-storage-workspace-cap.test.ts new file mode 100644 index 00000000..b7dc7668 --- /dev/null +++ b/tests/unit/api/object-storage-workspace-cap.test.ts @@ -0,0 +1,45 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + computeWorkspaceUsedBytes, + isOverWorkspaceCap, +} from "../../../apps/api/src/lib/object-storage.js"; + +describe("isOverWorkspaceCap", () => { + it("is disabled (never over) when maxGb is 0", () => { + expect(isOverWorkspaceCap(999 * 1024 ** 3, 0)).toBe(false); + }); + + it("is false when usage is under the cap", () => { + expect(isOverWorkspaceCap(5 * 1024 ** 3, 10)).toBe(false); + }); + + it("is true when usage exceeds the cap", () => { + expect(isOverWorkspaceCap(11 * 1024 ** 3, 10)).toBe(true); + }); +}); + +describe("computeWorkspaceUsedBytes", () => { + let root = ""; + afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = ""; + }); + + it("sums file sizes across uploads/ and outputs/ job dirs", async () => { + root = await mkdtemp(join(tmpdir(), "snapotter-wscap-")); + await mkdir(join(root, "uploads", "job1"), { recursive: true }); + await mkdir(join(root, "outputs", "job2"), { recursive: true }); + await writeFile(join(root, "uploads", "job1", "a.bin"), Buffer.alloc(1000)); + await writeFile(join(root, "outputs", "job2", "b.bin"), Buffer.alloc(2000)); + expect(await computeWorkspaceUsedBytes(root)).toBe(3000); + }); + + it("returns 0 for an empty or missing workspace", async () => { + root = await mkdtemp(join(tmpdir(), "snapotter-wscap-")); + expect(await computeWorkspaceUsedBytes(root)).toBe(0); + expect(await computeWorkspaceUsedBytes(join(root, "nope"))).toBe(0); + }); +}); diff --git a/tests/unit/security/security-svg-sanitize.test.ts b/tests/unit/security/security-svg-sanitize.test.ts index c55a6e07..6d7a2074 100644 --- a/tests/unit/security/security-svg-sanitize.test.ts +++ b/tests/unit/security/security-svg-sanitize.test.ts @@ -312,6 +312,59 @@ describe("SVG sanitizer -- url() scheme blocking", () => { }); }); +// ── href scheme obfuscation: whitespace + unquoted (defense-in-depth) ──────── + +describe("SVG sanitizer -- href scheme whitespace/unquoted bypass", () => { + it("blocks an unquoted javascript: URI in href", () => { + const svg = wrapSvg("x"); + const result = sanitize(svg); + expect(result).not.toContain("javascript:"); + }); + + it("blocks a javascript: URI with leading whitespace inside quotes", () => { + const svg = wrapSvg('x'); + const result = sanitize(svg); + expect(result).not.toContain("javascript:"); + }); + + it("blocks javascript: on xlink:href", () => { + const svg = wrapSvg( + 'x', + 'xmlns:xlink="http://www.w3.org/1999/xlink"', + ); + const result = sanitize(svg); + expect(result).not.toContain("javascript:"); + }); +}); + +// ── Extended animation / event elements ────────────────────────────────────── + +describe("SVG sanitizer -- extended animation elements", () => { + it("strips with a javascript: value", () => { + const svg = wrapSvg(''); + const result = sanitize(svg); + expect(result).not.toContain(" and its ", () => { + const svg = wrapSvg(''); + const result = sanitize(svg); + expect(result).not.toContain(" SVG-Tiny event-handler element", () => { + const svg = wrapSvg( + 'alert(1)', + 'xmlns:ev="http://www.w3.org/2001/xml-events"', + ); + const result = sanitize(svg); + expect(result).not.toContain(" { diff --git a/tests/unit/shared/subprocess-limit.test.ts b/tests/unit/shared/subprocess-limit.test.ts new file mode 100644 index 00000000..157e2ef9 --- /dev/null +++ b/tests/unit/shared/subprocess-limit.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { wrapWithMemoryLimit } from "../../../packages/shared/src/subprocess-limit.js"; + +const KEY = "SUBPROCESS_MEMORY_LIMIT_MB"; +const orig = process.env[KEY]; + +describe("wrapWithMemoryLimit", () => { + afterEach(() => { + if (orig === undefined) delete process.env[KEY]; + else process.env[KEY] = orig; + }); + + it("returns the command unchanged when the limit is unset (default)", () => { + delete process.env[KEY]; + expect(wrapWithMemoryLimit("ffmpeg", ["-i", "a.mp4"])).toEqual(["ffmpeg", ["-i", "a.mp4"]]); + }); + + it("returns the command unchanged when the limit is 0 or non-numeric", () => { + process.env[KEY] = "0"; + expect(wrapWithMemoryLimit("gs", ["-dSAFER"])).toEqual(["gs", ["-dSAFER"]]); + process.env[KEY] = "not-a-number"; + expect(wrapWithMemoryLimit("gs", ["-dSAFER"])).toEqual(["gs", ["-dSAFER"]]); + }); + + it("wraps in an ulimit -v sh shim (limit in KB) when a positive MB limit is set", () => { + process.env[KEY] = "512"; + const [bin, args] = wrapWithMemoryLimit("ffmpeg", ["-i", "in.mp4", "out.mp4"]); + expect(bin).toBe("/bin/sh"); + expect(args[0]).toBe("-c"); + expect(args[1]).toContain("ulimit -v"); + expect(args[1]).toContain('exec "$@"'); + // sh -c