From 2b835e2bee88327ec67fe6539466d2eb9b4f87b9 Mon Sep 17 00:00:00 2001 From: dedmonwalkin Date: Thu, 16 Apr 2026 15:43:06 -0500 Subject: [PATCH] Launch-readiness: reframe scope, security hardening, consent docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product reframe - README leads with captions + AD Draft Assist; removed sign-language claim - New LIMITATIONS.md: honest about mock vs production, WER, AD human review, WFD/WASLI 2018 rationale for dropping the sign-language module Security - Path-traversal gate: JOB_ID_RE validation before every lookup; resolved upload paths asserted inside UPLOADS_DIR - Timing-safe API key compare; dropped ?api_key= query-string auth - TRUSTED_PROXY gate for X-Forwarded-For (no spoofed client IPs by default) - SSRF guard (safeFetch) refuses private/loopback/ULA; streaming response cap; allowHttpLocalhost for intentional local inference - MODEL_FALLBACK_ON_ERROR=false by default; fallback outputs tagged _provider: 'mock-fallback' so silent degradation is visible - Magic-byte sniff on uploads (mp4/webm/wav/ogg/mp3) — declared MIME must match bytes; stream cleanup on failure + orphan-dir sweep - 1MB cap on JSON bodies with 413 response (drain, don't destroy) - Loud warn on empty API_KEYS when AUTH_DISABLED not set - Snapshot retention (default 20) to bound postgres growth - Dockerfile: non-root 'app' user, tini as PID 1 Architecture - Config validation with parseNumber/parseBool, fail-fast + cached - structuredClone in state serializer to decouple snapshots from live Map - Pipeline cancellation check per chunk (JobCancelledError) - SSE progress replay on attach + 15s heartbeat + cleanup on close - .unref() on snapshot/cleanup intervals so process exits cleanly Cookies & consent - COOKIES.md with 4 categories (strictly-necessary / functional / analytics opt-in / advertising never) - PRIVACY.md: GDPR table, controller vs operator, CCPA note - SECURITY.md: disclosure via GH Security tab, SLAs, operator hardening - /cookies and /privacy routes render shared-layout markdown Tests - test/jobService.test.js rewritten with dynamic import inside before() so AUTH_DISABLED/PORT reach runtimeConfig before it caches - New tests: cookie/privacy routes, security headers, path-traversal rejection, job-id regex, unsupported file type, magic-byte mismatch, 413 on oversize body All 75 tests pass. Co-Authored-By: Claude Opus 4.7 --- .env.example | 21 ++ COOKIES.md | 48 ++++ Dockerfile | 17 +- LIMITATIONS.md | 57 +++++ PRIVACY.md | 52 ++++ README.md | 84 ++++--- SECURITY.md | 53 ++++ docker-compose.yml | 10 +- src/config/runtime.js | 70 +++++- src/server.js | 230 ++++++++++++++---- src/services/cleanupService.js | 31 ++- src/services/jobService.js | 18 +- .../persistence/postgresPersistence.js | 20 ++ src/services/persistence/stateSerializer.js | 6 +- src/services/pipelineService.js | 39 ++- src/services/providers/httpModelProvider.js | 103 ++++---- src/services/providers/translationProvider.js | 65 +++-- src/services/uiService.js | 55 +++++ src/utils/http.js | 24 +- src/utils/safeFetch.js | 128 ++++++++++ src/utils/upload.js | 99 ++++++-- test/jobService.test.js | 79 ++++-- 22 files changed, 1110 insertions(+), 199 deletions(-) create mode 100644 .env.example create mode 100644 COOKIES.md create mode 100644 LIMITATIONS.md create mode 100644 PRIVACY.md create mode 100644 SECURITY.md create mode 100644 src/utils/safeFetch.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2265ff3 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Copy to .env and set values for production. +# Never commit .env. The default compose password is fine for localhost ONLY. + +POSTGRES_PASSWORD=change-me-to-a-long-random-string + +# Comma-separated API keys. Empty + AUTH_DISABLED=false → all non-public endpoints return 401. +API_KEYS= + +# Set AUTH_DISABLED=true to run without keys (local dev only). +AUTH_DISABLED=false + +# Set to true only when you're behind a trusted reverse proxy (nginx, Traefik, ALB). +# Required for X-Forwarded-For to be honoured for rate-limit client IP detection. +TRUSTED_PROXY=false + +# Model + translation (see README). Default is mock. +MODEL_PROVIDER=mock +TRANSLATION_PROVIDER=mock + +# Do NOT enable in production. Silent mock fallback hides real outages. +MODEL_FALLBACK_ON_ERROR=false diff --git a/COOKIES.md b/COOKIES.md new file mode 100644 index 0000000..3dd8fa4 --- /dev/null +++ b/COOKIES.md @@ -0,0 +1,48 @@ +# Cookie policy + +Last updated: 2026-04-16 + +## Short version + +At the time of this writing, **self-hosted Accessibility Lite sets no cookies by default**. The pages served by this server do not set tracking cookies, analytics cookies, or advertising cookies. The only time you will ever see a cookie from this service is if the operator of a deployment explicitly enables one of the categories below. + +## Full version + +### What a cookie is, in one sentence + +A cookie is a small piece of text your browser stores on behalf of a site and sends back on future requests to that site. It is the mechanism behind sign-in, shopping carts, and a great deal of tracking. + +### Cookies this service may set + +The only cookies a standard deployment of this service should ever set are in the first two categories below. Anything else is the decision of whoever is operating your particular deployment. + +1. **Strictly necessary (no consent required, cannot be disabled).** + - `session` — HttpOnly, Secure, SameSite=Lax. Keeps a signed-in operator signed in across requests. Only set after authentication. **Not present in the public open-source release; will appear when user accounts ship.** + - `csrf` — Protects state-changing HTML forms against cross-site request forgery. Paired with a matching header on submission. **Not present in the current release.** + +2. **Functional (opt-in).** + - `ui_prefs` — Remembers your player UI choices (caption font size, high-contrast mode, preferred language). Not sent to any third party. Can be cleared from the player page. + +3. **Analytics (opt-in, off by default).** + - If and only if the operator enables analytics, an anonymous identifier may be set with a 90-day lifetime. The identifier is not linked to your name, email, IP address, or any account. It is used only to count usage of features (e.g. "how many people downloaded a VTT file this week"). **Never shared with advertising networks. Never sold.** + +4. **Advertising.** + - Never. This service does not support advertising cookies, never has, and never will in its open-source form. + +### Consent and control + +Under the EU ePrivacy Directive, UK PECR, and state laws in the US (e.g. California CCPA/CPRA, Colorado, Connecticut), analytics cookies require opt-in consent from residents of those jurisdictions. The hosted deployment of Accessibility Lite shows a consent banner the first time a visitor loads a page that would set an analytics cookie. Declining keeps the cookie off. Changing your mind is one click from the footer link "Cookie preferences". + +Strictly-necessary cookies do not require consent and cannot be turned off without breaking sign-in. + +### Third parties + +A standard self-hosted Accessibility Lite deployment makes no outbound requests to third parties from the browser. If you have enabled an optional integration (Sentry, PostHog, DeepL, LibreTranslate), your browser may make requests that involve cookies or similar technologies operated by those vendors. Each of them publishes their own cookie policy. + +### Changes to this policy + +We'll note changes in the repository changelog and update the "last updated" date above. + +### Contact + +Open an issue at [github.com/dedmonwalkin/accessibility-lite](https://github.com/dedmonwalkin/accessibility-lite) or email the address in [SECURITY.md](SECURITY.md). diff --git a/Dockerfile b/Dockerfile index 5aeb192..064f062 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ +# node:20-alpine — pin by digest before shipping a release tag. Example: +# FROM node:20-alpine@sha256: +# Floating tags are accepted here during development only. FROM node:20-alpine -RUN apk add --no-cache ffmpeg +RUN apk add --no-cache ffmpeg tini WORKDIR /app @@ -8,9 +11,19 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY src/ src/ +COPY README.md LIMITATIONS.md COOKIES.md PRIVACY.md SECURITY.md ./ -RUN mkdir -p uploads +# Run as a non-root user. If an attacker ever achieves RCE via ffmpeg or +# Node, they do not have container root and cannot escalate to write +# outside the uploads volume. +RUN addgroup -S app && adduser -S app -G app \ + && mkdir -p uploads \ + && chown -R app:app /app uploads + +USER app EXPOSE 3000 +# tini is PID 1 so SIGTERM / SIGINT reach Node for graceful shutdown. +ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "src/server.js"] diff --git a/LIMITATIONS.md b/LIMITATIONS.md new file mode 100644 index 0000000..e53a237 --- /dev/null +++ b/LIMITATIONS.md @@ -0,0 +1,57 @@ +# Limitations + +Accessibility Lite is an open-source draft-assist tool. It automates the parts of media accessibility where automation is useful today, and it is explicit about the parts where automation is not. Please read this document before relying on any output for legal compliance or for users who depend on the output. + +## What it does well + +- **Captions from clear speech.** When the default provider is swapped for [whisper.cpp](https://github.com/ggerganov/whisper.cpp) (or a comparable Whisper deployment), word-error rates are typically in the 5–10% range for clean English speech and degrade predictably with noise, accent, overlapping speakers, and domain-specific vocabulary. That is good enough to *seed* a human captioning workflow and, in low-stakes contexts, good enough as-is. +- **Format fidelity.** WebVTT and TTML outputs conform to the spec well enough for major media players, LMS systems, and broadcast delivery platforms. +- **Translation.** With DeepL or LibreTranslate wired up, caption translation quality matches the underlying service. It is not better and not worse. +- **Self-hosting.** The pipeline runs entirely offline against whisper.cpp + Ollama + LibreTranslate with no outbound network traffic — a requirement for many EU public-sector buyers. + +## What is *draft-quality* and requires human review + +### Audio Description ("AD Draft Assist") + +Vision-language models can produce a first-pass description of what is visible on screen during dialogue gaps. That is not the same as writing an audio description that works for a blind audience. + +- Timing is approximate. The model does not know when dialogue will resume, so descriptions frequently overrun and talk over returning speech. +- Tone, voice, and narrative choice (what to describe, what to omit) are the craft of professional describers. Model output reliably fails on these. +- Blind users surveyed in public studies (AMI, RNIB) consistently reject generic LLM-produced AD when it is presented as finished product. + +**How to use the AD output:** as the first draft a sighted describer polishes in a tool like YouDescribe or a professional describer reviews in Amara. The UI labels these outputs as drafts and the JSON download includes a `requires_human_review: true` field. + +### Transcript quality on hard audio + +Whisper degrades sharply on: heavy accents the model has not seen, whispered or shouted speech, sustained background noise, music under dialogue, and speaker overlap. If your content includes these, expect meaningful manual correction. The captions endpoint does not guarantee WCAG "equivalent" quality on adversarial audio. + +### Simplified captions + +The `caption_style: "simplified"` mode truncates and compresses per-segment text. It is intended for readers who find verbatim text fast-moving. It is **not** a plain-language adaptation in the technical sense (CEFR A2, Easy Read, Leichte Sprache). Do not market it as such. + +## What is **not in this release** + +### Sign language + +Earlier drafts of this project included a "sign language overlay" feature generated from gloss tokens. We have removed it from the public surface for v0.2. + +Gloss is a written linguistic annotation; it is not a sign language. Signing avatars driven by gloss or by English text have been opposed by the [World Federation of the Deaf and WASLI since 2018](https://wfdeaf.org/news/resources/wfd-wasli-statement-use-signing-avatars/) and by NAD, BDA, and EUD. Shipping avatar overlays labelled as "ASL / AUSLAN / LIBRAS / HKSL" that are in fact word-for-word transliterations of English is not accessibility — it is inaccessible output presented as accessibility, and the Deaf community has been clear that it causes harm. + +If we revisit sign language in a future version, it will be: + +1. Led by Deaf signers and a Deaf-led organization. +2. Using human-signed video clips, not generated avatars. +3. Scoped to a small number of languages at a time, curated in partnership with that community. +4. Marketed honestly. + +We will not ship avatar-based sign output before then. If you fork the project and add it back, please read the WFD statement first. + +## Compliance note + +Nothing in this repository is legal advice. Accessibility Lite can *help* you meet WCAG 2.2 A captions (Success Criterion 1.2.2) and AA audio description (Success Criterion 1.2.5) obligations when combined with appropriate human review. It does not by itself make a site or service conformant, and it does not produce outputs that should be labelled as "WCAG AA captions" without the review step for anything beyond the lowest-stakes content. + +For EU buyers subject to EN 301 549 or the European Accessibility Act: use outputs as drafts and keep the human-in-the-loop step in your procurement requirement. + +## Reporting problems with outputs + +If an output is actively harmful — mistranscribed in a way that changes meaning, or describes a scene in a way that misleads a blind listener — please open an issue labelled `output-quality` with the input source (if you can share it) and the output. These reports directly inform provider configuration defaults. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..9b1ed43 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,52 @@ +# Privacy notice + +Last updated: 2026-04-16 + +This notice describes how a deployment of Accessibility Lite handles personal data. This file is a template; the operator of the specific deployment you are using may publish their own version. Always check the `/privacy` page of the site you are actually using. + +## Who is the controller + +You are reading this from a self-hostable, open-source project. When you upload a file to a hosted deployment, the **operator of that deployment** is the data controller. The original authors of this project ([Isabella & Tan](https://github.com/dedmonwalkin)) are not controllers of any data you submit to a third-party deployment. The authors are controllers only of the data submitted directly to deployments we operate, if and when any exist. + +## What we process and why + +| Category | Purpose | Legal basis (GDPR) | Retention | +|---|---|---|---| +| Uploaded media (video/audio) | Transcription, description, caption generation | Contract / consent | Default 24 hours, then deleted | +| Transcripts & derived captions | Deliver the output you asked for | Contract / consent | Same as uploaded media | +| API key (if configured) | Authenticate requests | Contract / legitimate interests | Until revoked | +| Request IP | Rate limiting, abuse prevention | Legitimate interests | Rolling 60-second window in memory; not persisted | +| Audit log (operation, timestamp, job id) | Debugging, support | Legitimate interests | Same as job retention | +| Anonymous analytics (if operator enables) | Improve the product | Consent | 90 days | + +**What we do not process:** We do not sell personal data. We do not transmit your media to advertising networks. We do not share your content with model vendors beyond the inference step you configured. + +## Your rights + +If you are in the EU / UK: you have the right to access, rectify, erase, restrict, port, and object. Contact the operator of the deployment you are using. If they do not respond, you can lodge a complaint with your data protection authority. + +If you are in California: you have the right to know, delete, correct, and opt out of any sale or share. We do not sell or share in the CCPA sense. + +## International transfers + +A self-hosted deployment that runs entirely locally performs no international transfer of your data. A hosted deployment may. Any hosted deployment we offer will publish its transfer basis (Standard Contractual Clauses and a transfer impact assessment for EU transfers). + +## Subprocessors + +A standard self-hosted deployment has no subprocessors. A hosted deployment, if offered, will publish its subprocessor list here and notify users before adding new ones. + +## Children + +This service is not directed at children under 16. Do not upload media of children unless you have parental consent and a lawful basis for processing. + +## Security + +See [SECURITY.md](SECURITY.md) for our responsible-disclosure process and for the technical measures in place. + +## Changes + +Material changes are announced in the repository changelog with a minimum 30-day notice for the hosted tier. + +## Contact + +Open an issue at [github.com/dedmonwalkin/accessibility-lite](https://github.com/dedmonwalkin/accessibility-lite) for general questions. Use the contact in [SECURITY.md](SECURITY.md) for anything sensitive. diff --git a/README.md b/README.md index c1473db..12231e0 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,32 @@ # Accessibility Lite -Open source accessibility pipeline. Upload any video or audio file and get **captions**, **AI audio descriptions**, and **sign language overlays** — all from a single tool. +Open-source media accessibility pipeline. Upload a video or audio file and get **captions in 21 languages** plus **AI-drafted audio descriptions** that a human reviewer can polish before shipping. + +Self-hostable. MIT-licensed. Works offline with local models. ## Why this exists -> She had nothing she could do anymore. She couldn't drive, she couldn't read. But she would still try to watch as much TV as she could, because that was her habit. +Captioning at compliance quality is expensive and locked to proprietary vendors. Audio description is worse — it barely exists in automated form, and what exists is trapped inside closed platforms. Public-sector buyers in the EU cannot legally send media to US SaaS for processing, so they end up with no accessibility at all. -Captions exist in some tools. Audio descriptions barely exist in automated form. Nobody bundles all three accessibility modalities in a single open source pipeline. This project does. +Accessibility Lite is a single, self-hostable pipeline that covers the parts of [WCAG 2.2 Level A/AA](https://www.w3.org/TR/WCAG22/), [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/), and the [European Accessibility Act](https://eur-lex.europa.eu/eli/dir/2019/882/oj) (enforceable 28 June 2025) that automation can realistically help with: **captions and audio-description drafts**. ## What you get -- **Captions** — AI-powered transcription in WebVTT and TTML. Standard, simplified, or verbatim styles. 21 output languages. -- **Audio Descriptions** — AI-generated narration of visual content during dialogue gaps. -- **Sign Language** — Gloss tokens and sign cards for 10 sign languages (ASL, BSL, LSF, ISL, JSL, DGS, AUSLAN, LIBRAS, NZSL, HKSL). 6 overlay themes including high contrast and kid-friendly modes. -- **Shareable Player** — Each processed file gets a player page with live captions, sign overlays, and audio descriptions. Share the link with anyone. +- **Captions** — AI-powered transcription to [WebVTT](https://www.w3.org/TR/webvtt1/) and [TTML2](https://www.w3.org/TR/ttml2/). Standard, simplified, or verbatim styles. 21 output languages. +- **AD Draft Assist** — AI-generated *first-draft* narration of visual content during dialogue gaps. **Requires human review before publication.** See [LIMITATIONS.md](LIMITATIONS.md). +- **Shareable Player** — Each processed file gets a player page with live captions and audio descriptions. Share the link with anyone. + +> [!IMPORTANT] +> Outputs from AI models are drafts, not finished accessibility products. Read [LIMITATIONS.md](LIMITATIONS.md) before using outputs in compliance-bound workflows. ## Quick start ```bash -# Clone and install git clone https://github.com/dedmonwalkin/accessibility-lite.git cd accessibility-lite npm install -# Start (uses mock AI providers — no API keys needed) +# Mock providers — no API keys, no external calls. Demo only. npm start # Open http://localhost:3000 ``` @@ -43,11 +46,14 @@ docker run -p 3000:3000 accessibility-lite Run the full pipeline locally with zero API costs: -| Component | Tool | Cost | -|-----------|------|------| -| Transcription | [whisper.cpp](https://github.com/ggerganov/whisper.cpp) server mode | Free | -| Vision / Descriptions | [Ollama](https://ollama.ai) + Gemma or LLaVA | Free | -| Translation | [LibreTranslate](https://github.com/LibreTranslate/LibreTranslate) | Free | +| Component | Tool | License | Cost | +|-----------|------|---------|------| +| Transcription | [whisper.cpp](https://github.com/ggerganov/whisper.cpp) server mode | MIT | Free | +| Vision / descriptions | [Ollama](https://ollama.ai) + Gemma or LLaVA | Apache 2.0 / Llama Community | Free | +| Translation | [LibreTranslate](https://github.com/LibreTranslate/LibreTranslate) | **AGPL-3.0** — see note | Free | + +> [!WARNING] +> LibreTranslate is AGPL-3.0. If you expose it over a network as part of a hosted service you offer to others, AGPL obligations attach to the combined work. For a managed SaaS, prefer DeepL or an in-house translation shim. For private self-hosting or internal use, AGPL is usually not a practical issue — but confirm with your counsel. Configure via environment variables: @@ -67,15 +73,21 @@ TRANSLATION_HTTP_BASE_URL=http://localhost:5000 | `MODEL_HTTP_BASE_URL` | — | URL of model inference server | | `MODEL_HTTP_API_KEY` | — | Bearer token for model server | | `MODEL_HTTP_TIMEOUT_MS` | `4000` | Model request timeout | +| `MODEL_FALLBACK_ON_ERROR` | `false` | If `true`, silent fallback to mock on provider failure. **Never enable in production.** | | `TRANSLATION_PROVIDER` | `mock` | `mock`, `libretranslate`, or `deepl` | | `TRANSLATION_HTTP_BASE_URL` | — | LibreTranslate URL | | `TRANSLATION_API_KEY` | — | DeepL or LibreTranslate key | -| `API_KEYS` | — | Comma-separated auth tokens (empty = public) | +| `API_KEYS` | — | Comma-separated auth tokens. **Empty requires `AUTH_DISABLED=true`.** | +| `AUTH_DISABLED` | `false` | Must be `true` to run without API keys. | +| `TRUSTED_PROXY` | `false` | Set `true` only when behind a trusted reverse proxy; enables `X-Forwarded-For` parsing. | | `RATE_LIMIT_RPM` | `60` | Requests per minute per IP (0 = disabled) | | `MAX_UPLOAD_MB` | `500` | Max upload file size | +| `MAX_JSON_BODY_BYTES` | `1048576` | Max JSON request body (1 MB) | | `ENABLE_POSTGRES` | `false` | Enable Postgres persistence | | `DATABASE_URL` | — | Postgres connection string | | `SNAPSHOT_INTERVAL_MS` | `300000` | Auto-snapshot interval (5 min) | +| `SNAPSHOT_RETENTION` | `20` | Keep last N snapshots (0 = keep all, not recommended) | +| `HTTP_PROVIDER_MAX_RESPONSE_BYTES` | `10485760` | Response cap for model/translation HTTP calls (10 MB) | ## Architecture @@ -83,7 +95,7 @@ TRANSLATION_HTTP_BASE_URL=http://localhost:5000 src/ server.js — HTTP server, auth, rate limiting, SSE config/ - runtime.js — Environment variable config + runtime.js — Env config, fail-fast validation accessibilityCatalog.js — Supported languages, styles, themes data/ store.js — In-memory state (Maps + audit log) @@ -97,11 +109,11 @@ src/ providers/ modelProvider.js — Factory: mock | http mockModelProvider.js — Mock transcription and inference - httpModelProvider.js — HTTP model gateway with fallback + httpModelProvider.js — HTTP model gateway (SSRF-guarded, response-capped) translationProvider.js — Factory: mock | libretranslate | deepl (with cache) persistence/ persistenceService.js — Noop/Postgres adapter with retry - postgresPersistence.js — JSONB snapshot table + postgresPersistence.js — JSONB snapshot table (with retention) stateSerializer.js — Export/import all Maps ``` @@ -119,21 +131,23 @@ src/ | `GET` | `/v1/jobs/:id/progress` | SSE progress stream | | `GET` | `/v1/jobs/:id/captions.vtt` | Download WebVTT captions | | `GET` | `/v1/jobs/:id/captions.ttml` | Download TTML captions | -| `GET` | `/v1/jobs/:id/audio-description.json` | Download audio descriptions | -| `GET` | `/v1/jobs/:id/sign-data.json` | Download sign language data | +| `GET` | `/v1/jobs/:id/audio-description.json` | Download audio-description draft | | `GET` | `/v1/jobs/:id/media` | Stream original media (range requests supported) | | `GET` | `/jobs/:id/options` | Options page (HTML) | | `GET` | `/jobs/:id/results` | Results page (HTML) | | `GET` | `/player/:id` | Shareable player page | +| `GET` | `/cookies` | Cookie policy | +| `GET` | `/privacy` | Privacy notice | ## How processing works 1. Upload a video or audio file -2. Audio is extracted to WAV (16kHz mono) via ffmpeg -3. Audio is split into chunks (5 seconds by default) -4. Each chunk is transcribed, then run through perception, accessibility, and sign inference -5. Captions are translated to all selected output languages -6. Results are assembled and available via download links or the player page +2. The file is validated against allowed MIME types **and** its first-bytes magic number +3. Audio is extracted to WAV (16 kHz mono) via ffmpeg +4. Audio is split into chunks (5 seconds by default) +5. Each chunk is transcribed, then run through perception and accessibility inference +6. Captions are translated to selected output languages +7. Results are assembled and available via download links or the player page ## Persistence @@ -151,11 +165,23 @@ The app automatically snapshots state to Postgres on: - Every 5 minutes (configurable) - Graceful shutdown -On startup, the latest snapshot is loaded automatically. +On startup, the latest snapshot is loaded automatically. Old snapshots beyond `SNAPSHOT_RETENTION` are pruned. ## Job expiry -Jobs and their uploaded files are automatically removed after 24 hours. The cleanup runs hourly and on startup. +Jobs and their uploaded files are automatically removed after 24 hours. The cleanup runs hourly, on startup, and sweeps orphan directories left behind by failed uploads. + +## Privacy & cookies + +This service sets a minimal set of strictly-necessary cookies only (no tracking, no third-party analytics by default). See [COOKIES.md](COOKIES.md) and [PRIVACY.md](PRIVACY.md). + +## Limitations + +AI outputs from this pipeline are drafts, not finished accessibility products. Read [LIMITATIONS.md](LIMITATIONS.md) before relying on outputs for legal compliance or for blind / Deaf / hard-of-hearing audiences. + +## Security + +Report vulnerabilities via [SECURITY.md](SECURITY.md). ## Tests @@ -163,12 +189,10 @@ Jobs and their uploaded files are automatically removed after 24 hours. The clea npm test ``` -69+ tests covering providers, pipeline, UI generation, auth, rate limiting, and HTML escaping. - ## License MIT ## Built by -[Isabella & Tan](https://github.com/dedmonwalkin) +[Isabella & Tan](https://github.com/dedmonwalkin) — with thanks to an audience of one whose evening TV habit reminded us this work matters. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cdaeb2f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security policy + +## Reporting a vulnerability + +Please do **not** report security vulnerabilities through public GitHub issues. + +Instead, email the maintainer using the "Report a vulnerability" button on the repository's Security tab (GitHub private vulnerability reporting), or open an issue explicitly requesting private follow-up and we will respond with contact details. + +We aim to: + +- Acknowledge receipt within 3 business days +- Provide an initial assessment within 7 business days +- Publish a fix within 90 days for High/Critical issues + +Please include: + +- A description of the issue +- Steps to reproduce or proof-of-concept +- Impact assessment +- Suggested mitigation if you have one + +## Scope + +In scope: + +- The code in this repository +- The default Docker image (`Dockerfile`) +- Configuration examples in `docker-compose.yml` and `.env.example` + +Out of scope: + +- Third-party models (Whisper, LLaVA, DeepL, LibreTranslate) — report those upstream +- Self-hosted deployments you do not control +- Issues requiring physical access, social engineering, or malicious admin + +## Supported versions + +The latest minor release is supported. Older minors receive fixes only for Critical issues. See [CHANGELOG.md](CHANGELOG.md) (if present) for release history. + +## Hardening checklist for operators + +If you run Accessibility Lite in production, please: + +1. Set `API_KEYS` — never deploy with `AUTH_DISABLED=true` on a public endpoint. +2. Run behind a TLS-terminating reverse proxy and set `TRUSTED_PROXY=true` so rate limiting works. +3. Leave `MODEL_FALLBACK_ON_ERROR` at its default (`false`). Silent fallback hides real outages and serves fabricated output. +4. Rotate `DATABASE_URL` credentials from their dev defaults. Never commit `.env`. +5. Review [LIMITATIONS.md](LIMITATIONS.md) before advertising outputs as compliant with any specific standard. +6. Keep the base image and ffmpeg up to date. ffmpeg CVEs are the dominant path to RCE in any transcoding pipeline. + +## Acknowledgements + +Researchers who report valid issues are credited in release notes, unless they ask otherwise. diff --git a/docker-compose.yml b/docker-compose.yml index 4868881..e145980 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,7 @@ +# Local-development compose file. The default Postgres password below is +# "alite" — acceptable for localhost, unsafe for any networked deployment. +# For production, provide secrets via an env file (see .env.example) or a +# secret manager. services: app: build: . @@ -9,8 +13,10 @@ services: - TRANSLATION_PROVIDER=mock - MAX_UPLOAD_MB=500 - ENABLE_POSTGRES=true - - DATABASE_URL=postgresql://alite:alite@postgres:5432/accessibility_lite + - DATABASE_URL=postgresql://alite:${POSTGRES_PASSWORD:-alite}@postgres:5432/accessibility_lite - SNAPSHOT_INTERVAL_MS=300000 + # Development-only: open endpoints without API keys. Do NOT use in production. + - AUTH_DISABLED=true volumes: - uploads:/app/uploads depends_on: @@ -21,7 +27,7 @@ services: image: postgres:16-alpine environment: POSTGRES_USER: alite - POSTGRES_PASSWORD: alite + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-alite} POSTGRES_DB: accessibility_lite volumes: - pgdata:/var/lib/postgresql/data diff --git a/src/config/runtime.js b/src/config/runtime.js index f6aad27..64a5e10 100644 --- a/src/config/runtime.js +++ b/src/config/runtime.js @@ -1,23 +1,71 @@ +function parseNumber(name, raw, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + if (!Number.isFinite(n)) { + throw new Error(`Invalid ${name}: "${raw}" is not a number`); + } + if (n < min || n > max) { + throw new Error(`Invalid ${name}: ${n} out of range [${min}, ${max}]`); + } + return n; +} + +function parseBool(raw, fallback) { + if (raw === undefined) return fallback; + const v = String(raw).toLowerCase(); + if (v === 'true' || v === '1' || v === 'yes') return true; + if (v === 'false' || v === '0' || v === 'no' || v === '') return false; + return fallback; +} + +let cached = null; + export function runtimeConfig() { + if (cached) return cached; const env = process.env; - return { + + const apiKeys = env.API_KEYS + ? env.API_KEYS.split(',').map(k => k.trim()).filter(Boolean) + : []; + const authDisabled = parseBool(env.AUTH_DISABLED, false); + if (apiKeys.length === 0 && !authDisabled) { + console.warn( + '[accessibility-lite] WARNING: API_KEYS is empty and AUTH_DISABLED is not set. ' + + 'All non-public endpoints will reject requests. ' + + 'Set API_KEYS=key1,key2 for production or AUTH_DISABLED=true to run open (e.g. for local dev).' + ); + } + + cached = { nodeEnv: env.NODE_ENV || 'development', - port: Number(env.PORT || 3000), - maxUploadMb: Number(env.MAX_UPLOAD_MB || 500), - chunkDurationMs: Number(env.CHUNK_DURATION_MS || 5000), + port: parseNumber('PORT', env.PORT, 3000, { min: 1, max: 65535 }), + maxUploadMb: parseNumber('MAX_UPLOAD_MB', env.MAX_UPLOAD_MB, 500, { min: 1, max: 10240 }), + maxJsonBodyBytes: parseNumber('MAX_JSON_BODY_BYTES', env.MAX_JSON_BODY_BYTES, 1024 * 1024, { min: 1024, max: 100 * 1024 * 1024 }), + chunkDurationMs: parseNumber('CHUNK_DURATION_MS', env.CHUNK_DURATION_MS, 5000, { min: 500, max: 600_000 }), modelProvider: env.MODEL_PROVIDER || 'mock', modelHttpBaseUrl: env.MODEL_HTTP_BASE_URL || '', modelHttpApiKey: env.MODEL_HTTP_API_KEY || '', - modelHttpTimeoutMs: Number(env.MODEL_HTTP_TIMEOUT_MS || 4000), + modelHttpTimeoutMs: parseNumber('MODEL_HTTP_TIMEOUT_MS', env.MODEL_HTTP_TIMEOUT_MS, 4000, { min: 100, max: 600_000 }), + modelFallbackOnError: parseBool(env.MODEL_FALLBACK_ON_ERROR, false), + httpProviderMaxResponseBytes: parseNumber('HTTP_PROVIDER_MAX_RESPONSE_BYTES', env.HTTP_PROVIDER_MAX_RESPONSE_BYTES, 10 * 1024 * 1024, { min: 1024, max: 1024 * 1024 * 1024 }), translationProvider: env.TRANSLATION_PROVIDER || 'mock', translationHttpBaseUrl: env.TRANSLATION_HTTP_BASE_URL || '', translationApiKey: env.TRANSLATION_API_KEY || '', - translationCacheTtlMs: Number(env.TRANSLATION_CACHE_TTL_MS || 3600000), - translationCacheMaxSize: Number(env.TRANSLATION_CACHE_MAX_SIZE || 10000), - apiKeys: env.API_KEYS ? env.API_KEYS.split(',').map(k => k.trim()).filter(Boolean) : [], - rateLimitRpm: Number(env.RATE_LIMIT_RPM || 60), - enablePostgres: (env.ENABLE_POSTGRES || 'false').toLowerCase() === 'true', + translationCacheTtlMs: parseNumber('TRANSLATION_CACHE_TTL_MS', env.TRANSLATION_CACHE_TTL_MS, 3_600_000, { min: 0 }), + translationCacheMaxSize: parseNumber('TRANSLATION_CACHE_MAX_SIZE', env.TRANSLATION_CACHE_MAX_SIZE, 10_000, { min: 0 }), + apiKeys, + authDisabled, + trustedProxy: parseBool(env.TRUSTED_PROXY, false), + rateLimitRpm: parseNumber('RATE_LIMIT_RPM', env.RATE_LIMIT_RPM, 60, { min: 0 }), + enablePostgres: parseBool(env.ENABLE_POSTGRES, false), databaseUrl: env.DATABASE_URL || '', - snapshotIntervalMs: Number(env.SNAPSHOT_INTERVAL_MS || 300000) + snapshotIntervalMs: parseNumber('SNAPSHOT_INTERVAL_MS', env.SNAPSHOT_INTERVAL_MS, 300_000, { min: 0 }), + snapshotRetention: parseNumber('SNAPSHOT_RETENTION', env.SNAPSHOT_RETENTION, 20, { min: 0, max: 10_000 }) }; + return cached; +} + +// Test hook — forces a fresh read of process.env. Not for production use. +export function _resetRuntimeConfigForTests() { + cached = null; } diff --git a/src/server.js b/src/server.js index b003c9d..33f4949 100644 --- a/src/server.js +++ b/src/server.js @@ -1,10 +1,11 @@ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; +import crypto from 'node:crypto'; import { runtimeConfig } from './config/runtime.js'; -import { sendJson, sendHtml, parseJsonBody, parseUrl } from './utils/http.js'; +import { sendJson, sendHtml, parseJsonBody, parseUrl, PayloadTooLargeError } from './utils/http.js'; import { formatVtt, formatTtml } from './utils/vtt.js'; -import { jobService } from './services/jobService.js'; +import { jobService, isValidJobId, getUploadsDir } from './services/jobService.js'; import { jobEmitter } from './services/pipelineService.js'; import { uiService } from './services/uiService.js'; import { persistenceService } from './services/persistence/persistenceService.js'; @@ -21,6 +22,19 @@ import { } from './config/accessibilityCatalog.js'; const config = runtimeConfig(); +const UPLOADS_DIR = getUploadsDir(); + +const COOKIE_POLICY_HTML = buildStaticMarkdownPage('Cookie policy', 'COOKIES.md'); +const PRIVACY_POLICY_HTML = buildStaticMarkdownPage('Privacy notice', 'PRIVACY.md'); + +function buildStaticMarkdownPage(title, filename) { + try { + const raw = fs.readFileSync(path.resolve(filename), 'utf8'); + return uiService.buildDocPage(title, raw); + } catch { + return uiService.buildErrorPage(404, title, `${filename} not found in this deployment.`); + } +} // ── Auth ──────────────────────────────────────────────────────── @@ -28,19 +42,50 @@ export function isPublicPath(method, pathname) { if (method !== 'GET') return false; if (pathname === '/') return true; if (pathname === '/health') return true; + if (pathname === '/cookies') return true; + if (pathname === '/privacy') return true; if (pathname === '/v1/catalog') return true; if (pathname.startsWith('/player/')) return true; if (/^\/v1\/jobs\/[^/]+\/media$/.test(pathname)) return true; return false; } -export function authenticate(req, url) { - if (config.apiKeys.length === 0) return true; +function safeEqualString(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +} + +export function authenticate(req) { + if (config.authDisabled) return true; + if (config.apiKeys.length === 0) return false; // fail closed when unconfigured const auth = req.headers['authorization'] || ''; - const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; - if (config.apiKeys.includes(token)) return true; - const queryKey = url.searchParams.get('api_key') || ''; - return config.apiKeys.includes(queryKey); + if (!auth.startsWith('Bearer ')) return false; + const token = auth.slice(7).trim(); + if (!token) return false; + // Compare against every configured key in constant time; the any-match + // result is the OR of per-key constant-time compares, so total runtime + // does not leak which key (if any) was a prefix match. + let match = false; + for (const key of config.apiKeys) { + if (safeEqualString(token, key)) match = true; + } + return match; +} + +// ── Client IP resolution ──────────────────────────────────────── + +function clientIp(req) { + if (config.trustedProxy) { + const xff = req.headers['x-forwarded-for']; + if (typeof xff === 'string' && xff.length > 0) { + const first = xff.split(',')[0].trim(); + if (first) return first; + } + } + return req.socket.remoteAddress || 'unknown'; } // ── Rate Limiting ─────────────────────────────────────────────── @@ -74,7 +119,7 @@ function cleanupRateLimitWindows() { export function checkRateLimit(req) { if (config.rateLimitRpm <= 0) return null; cleanupRateLimitWindows(); - const ip = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; + const ip = clientIp(req); const now = Date.now(); const windowMs = 60 * 1000; const hits = (rateLimitWindows.get(ip) || []).filter((t) => now - t < windowMs); @@ -87,6 +132,17 @@ export function checkRateLimit(req) { return null; } +// ── Security headers ──────────────────────────────────────────── + +function setSecurityHeaders(res) { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'geolocation=(), camera=(), microphone=()'); + // CSP: inline scripts/styles are used by uiService; restrict to self otherwise. + res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'"); +} + // ── Routing helpers ───────────────────────────────────────────── function extractParams(pathname, pattern) { @@ -96,7 +152,8 @@ function extractParams(pathname, pattern) { const params = {}; for (let i = 0; i < patternParts.length; i++) { if (patternParts[i].startsWith(':')) { - params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]); + try { params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]); } + catch { return null; } } else if (patternParts[i] !== pathParts[i]) { return null; } @@ -104,6 +161,21 @@ function extractParams(pathname, pattern) { return params; } +function requireValidJobId(id, res) { + if (!isValidJobId(id)) { + sendJson(res, 404, { error: 'Job not found' }); + return false; + } + return true; +} + +function resolveJobFilePath(filePath) { + const resolved = path.resolve(filePath); + const rootWithSep = UPLOADS_DIR + path.sep; + if (resolved !== UPLOADS_DIR && !resolved.startsWith(rootWithSep)) return null; + return resolved; +} + function simplifyText(text, maxWords = 12) { const cleaned = String(text || '').replace(/\[[A-Za-z-]{2,10}\]\s*/g, '').replace(/\s+/g, ' ').trim(); if (!cleaned) return ''; @@ -134,6 +206,8 @@ async function handleRequest(req, res) { const pathname = url.pathname; const method = req.method; + setSecurityHeaders(res); + try { // Rate limiting const retryAfter = checkRateLimit(req); @@ -143,7 +217,7 @@ async function handleRequest(req, res) { } // Auth - if (!isPublicPath(method, pathname) && !authenticate(req, url)) { + if (!isPublicPath(method, pathname) && !authenticate(req)) { return sendJson(res, 401, { error: 'Missing or invalid API key' }); } @@ -154,6 +228,13 @@ async function handleRequest(req, res) { return sendJson(res, 200, { status: 'ok', persistence: db }); } + if (pathname === '/cookies' && method === 'GET') { + return sendHtml(res, 200, COOKIE_POLICY_HTML); + } + if (pathname === '/privacy' && method === 'GET') { + return sendHtml(res, 200, PRIVACY_POLICY_HTML); + } + // Upload page if (pathname === '/' && method === 'GET') { return sendHtml(res, 200, uiService.buildUploadPage()); @@ -187,6 +268,7 @@ async function handleRequest(req, res) { // Job status let params = extractParams(pathname, '/v1/jobs/:id'); if (params && method === 'GET') { + if (!requireValidJobId(params.id, res)) return; const job = jobService.getJob(params.id); if (!job) return sendJson(res, 404, { error: 'Job not found' }); return sendJson(res, 200, job); @@ -195,12 +277,13 @@ async function handleRequest(req, res) { // Options page (HTML) params = extractParams(pathname, '/jobs/:id/options'); if (params && method === 'GET') { + if (!isValidJobId(params.id)) return sendHtml(res, 404, uiService.buildErrorPage(404, 'Job not found', 'The job you requested does not exist or has expired.')); const job = jobService.getJob(params.id); if (!job) return sendHtml(res, 404, uiService.buildErrorPage(404, 'Job not found', 'The job you requested does not exist or has expired.')); return sendHtml(res, 200, uiService.buildOptionsPage(job)); } - // Snapshot trigger + // Snapshot trigger (admin-scoped — rely on API key) if (pathname === '/v1/persistence/snapshot' && method === 'POST') { const body = await parseJsonBody(req); return sendJson(res, 200, await persistenceService.snapshot(body.reason || 'manual_api')); @@ -209,6 +292,7 @@ async function handleRequest(req, res) { // Start processing params = extractParams(pathname, '/v1/jobs/:id/process'); if (params && method === 'POST') { + if (!requireValidJobId(params.id, res)) return; const body = await parseJsonBody(req); const job = jobService.getJob(params.id); if (!job) return sendJson(res, 404, { error: 'Job not found' }); @@ -220,44 +304,55 @@ async function handleRequest(req, res) { // SSE progress params = extractParams(pathname, '/v1/jobs/:id/progress'); if (params && method === 'GET') { + if (!requireValidJobId(params.id, res)) return; const job = jobService.getJob(params.id); if (!job) return sendJson(res, 404, { error: 'Job not found' }); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - Connection: 'keep-alive' + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' }); + // Replay current progress so late attachers don't hang waiting for a + // fresh event after a fast completion. + res.write(`data: ${JSON.stringify({ type: 'progress', job_id: params.id, percent: job.progress, step: job.status })}\n\n`); + if (job.status === 'complete') { res.write(`data: ${JSON.stringify({ type: 'complete', job_id: params.id })}\n\n`); - res.end(); - return; + return res.end(); } if (job.status === 'error') { res.write(`data: ${JSON.stringify({ type: 'error', job_id: params.id, message: job.error })}\n\n`); - res.end(); - return; + return res.end(); } + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } + catch { /* noop */ } + }, 15_000); + const listener = (data) => { res.write(`data: ${JSON.stringify(data)}\n\n`); if (data.type === 'complete' || data.type === 'error') { - jobEmitter.removeListener(`job:${params.id}`, listener); - res.end(); + cleanup(); } }; - jobEmitter.on(`job:${params.id}`, listener); - - req.on('close', () => { + const cleanup = () => { + clearInterval(heartbeat); jobEmitter.removeListener(`job:${params.id}`, listener); - }); + try { res.end(); } catch { /* noop */ } + }; + jobEmitter.on(`job:${params.id}`, listener); + req.on('close', cleanup); return; } // Results page (HTML) params = extractParams(pathname, '/jobs/:id/results'); if (params && method === 'GET') { + if (!isValidJobId(params.id)) return sendHtml(res, 404, uiService.buildErrorPage(404, 'Job not found', 'The job you requested does not exist or has expired.')); const job = jobService.getJob(params.id); if (!job) return sendHtml(res, 404, uiService.buildErrorPage(404, 'Job not found', 'The job you requested does not exist or has expired.')); if (job.status !== 'complete') { @@ -270,6 +365,7 @@ async function handleRequest(req, res) { // Player page params = extractParams(pathname, '/player/:id'); if (params && method === 'GET') { + if (!isValidJobId(params.id)) return sendHtml(res, 404, uiService.buildErrorPage(404, 'Job not found', 'The job you requested does not exist or has expired.')); const job = jobService.getJob(params.id); if (!job) return sendHtml(res, 404, uiService.buildErrorPage(404, 'Job not found', 'The job you requested does not exist or has expired.')); if (job.status !== 'complete') return sendHtml(res, 200, uiService.buildErrorPage(202, 'Processing not complete', 'Your media is still being processed. Please check back shortly.', { autoRefresh: 5 })); @@ -280,6 +376,7 @@ async function handleRequest(req, res) { // Download: captions VTT params = extractParams(pathname, '/v1/jobs/:id/captions.vtt'); if (params && method === 'GET') { + if (!requireValidJobId(params.id, res)) return; const job = jobService.getJob(params.id); if (!job) return sendJson(res, 404, { error: 'Job not found' }); const outputs = jobService.getOutputs(params.id); @@ -300,6 +397,7 @@ async function handleRequest(req, res) { // Download: captions TTML params = extractParams(pathname, '/v1/jobs/:id/captions.ttml'); if (params && method === 'GET') { + if (!requireValidJobId(params.id, res)) return; const job = jobService.getJob(params.id); if (!job) return sendJson(res, 404, { error: 'Job not found' }); const outputs = jobService.getOutputs(params.id); @@ -320,18 +418,25 @@ async function handleRequest(req, res) { // Download: audio description params = extractParams(pathname, '/v1/jobs/:id/audio-description.json'); if (params && method === 'GET') { + if (!requireValidJobId(params.id, res)) return; const job = jobService.getJob(params.id); if (!job) return sendJson(res, 404, { error: 'Job not found' }); const outputs = jobService.getOutputs(params.id); if (!outputs) return sendJson(res, 404, { error: 'No outputs yet' }); const styled = personalizeAudioDesc(outputs.audioDescription, job.preferences.audio_description_style); - return sendJson(res, 200, { job_id: params.id, segments: styled }); + return sendJson(res, 200, { + job_id: params.id, + requires_human_review: true, + notice: 'AI-drafted audio description. Review before publishing — see LIMITATIONS.md.', + segments: styled + }); } // Download: sign data params = extractParams(pathname, '/v1/jobs/:id/sign-data.json'); if (params && method === 'GET') { + if (!requireValidJobId(params.id, res)) return; const job = jobService.getJob(params.id); if (!job) return sendJson(res, 404, { error: 'Job not found' }); const outputs = jobService.getOutputs(params.id); @@ -339,6 +444,7 @@ async function handleRequest(req, res) { return sendJson(res, 200, { job_id: params.id, + notice: 'Experimental. Not intended as a standalone sign-language product — see LIMITATIONS.md.', sign_language: job.preferences.sign_language, sign_presentation_mode: job.preferences.sign_presentation_mode, sign_overlay_theme: job.preferences.sign_overlay_theme, @@ -350,10 +456,15 @@ async function handleRequest(req, res) { // Serve original media params = extractParams(pathname, '/v1/jobs/:id/media'); if (params && method === 'GET') { + if (!requireValidJobId(params.id, res)) return; const job = jobService.getJob(params.id); if (!job) return sendJson(res, 404, { error: 'Job not found' }); - const filePath = job.file_path; + const filePath = resolveJobFilePath(job.file_path); + if (!filePath) { + console.error('media.path_traversal_blocked', { jobId: params.id, filePath: job.file_path }); + return sendJson(res, 404, { error: 'Media file not found' }); + } const ext = path.extname(filePath); const mimeType = MIME_TYPES[ext] || job.mime_type || 'application/octet-stream'; @@ -363,9 +474,23 @@ async function handleRequest(req, res) { const range = req.headers.range; if (range) { - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1; + const m = range.match(/^bytes=(\d*)-(\d*)$/); + if (!m) { + res.setHeader('Content-Range', `bytes */${stat.size}`); + return sendJson(res, 416, { error: 'Invalid Range' }); + } + let start = m[1] === '' ? null : Number(m[1]); + let end = m[2] === '' ? null : Number(m[2]); + if (start === null && end === null) { + res.setHeader('Content-Range', `bytes */${stat.size}`); + return sendJson(res, 416, { error: 'Invalid Range' }); + } + if (start === null) { start = stat.size - end; end = stat.size - 1; } + if (end === null) end = stat.size - 1; + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end >= stat.size || start > end) { + res.setHeader('Content-Range', `bytes */${stat.size}`); + return sendJson(res, 416, { error: 'Invalid Range' }); + } const chunkSize = end - start + 1; res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${stat.size}`, @@ -389,9 +514,11 @@ async function handleRequest(req, res) { } catch (err) { console.error('Request error:', err); if (!res.headersSent) { - sendJson(res, err.message.includes('Missing required') || err.message.includes('Unsupported file') ? 400 : 500, { - error: err.message - }); + if (err instanceof PayloadTooLargeError) { + return sendJson(res, 413, { error: 'Request body too large', limit_bytes: err.limit }); + } + const status = err.message.includes('Missing required') || err.message.includes('Unsupported file') || err.message.includes('do not match declared type') ? 400 : 500; + sendJson(res, status, { error: err.message }); } } } @@ -403,11 +530,13 @@ const originalEmit = jobEmitter.emit.bind(jobEmitter); jobEmitter.emit = function (event, data) { originalEmit(event, data); if (data && (data.type === 'complete' || data.type === 'error') && event.startsWith('job:')) { - persistenceService.snapshot(`job.${data.type}`).catch(() => {}); + persistenceService.snapshot(`job.${data.type}`).catch((err) => { + console.error('snapshot.failed', { reason: `job.${data.type}`, error: err.message }); + }); } }; -async function start() { +export async function start() { await persistenceService.configure(config); await persistenceService.loadLatest(); @@ -415,20 +544,28 @@ async function start() { console.log(`Accessibility Lite running on http://localhost:${config.port}`); console.log(`Model provider: ${config.modelProvider}`); console.log(`Persistence: ${config.enablePostgres ? 'postgres' : 'in-memory only'}`); + if (config.authDisabled) { + console.warn('AUTH_DISABLED=true — all endpoints are open. Do NOT run in production this way.'); + } + if (!config.trustedProxy) { + console.log('TRUSTED_PROXY=false — X-Forwarded-For header is ignored.'); + } }); - // Periodic snapshots + // Periodic snapshots. .unref() so the interval does not hold the event loop + // open — tests (and SIGTERM) can exit cleanly without clearing it. if (config.snapshotIntervalMs > 0) { setInterval(() => { - persistenceService.snapshot('interval').catch(() => {}); - }, config.snapshotIntervalMs); + persistenceService.snapshot('interval').catch((err) => { + console.error('snapshot.failed', { reason: 'interval', error: err.message }); + }); + }, config.snapshotIntervalMs).unref(); } // Job cleanup every hour (24h TTL) setInterval(() => { cleanupService.run(); - }, 60 * 60 * 1000); - // Run once on startup to clear stale jobs from previous sessions + }, 60 * 60 * 1000).unref(); cleanupService.run(); // Graceful shutdown @@ -443,7 +580,16 @@ async function start() { process.on('SIGINT', shutdown); } -start().catch((err) => { - console.error('Failed to start:', err); - process.exit(1); -}); +// Only auto-start when this file is the script entry point. Importing the +// module from tests (or any other code) does not open a port — the test +// harness calls start() explicitly so it can control lifecycle and port. +const entry = process.argv[1] || ''; +const invokedDirectly = import.meta.url === `file://${entry}` || entry.endsWith('src/server.js'); +if (invokedDirectly) { + start().catch((err) => { + console.error('Failed to start:', err); + process.exit(1); + }); +} + +export { server }; diff --git a/src/services/cleanupService.js b/src/services/cleanupService.js index d359d68..94eb843 100644 --- a/src/services/cleanupService.js +++ b/src/services/cleanupService.js @@ -14,7 +14,6 @@ export const cleanupService = { const createdAt = new Date(job.createdAt).getTime(); if (now - createdAt < ttlMs) continue; - // Remove upload files const jobDir = path.join(UPLOADS_DIR, jobId); try { fs.rmSync(jobDir, { recursive: true, force: true }); @@ -27,10 +26,34 @@ export const cleanupService = { removed++; } - if (removed > 0) { - store.log('cleanup.expired', { removed, ttlMs }); + const orphans = this.sweepOrphanDirs(ttlMs); + + if (removed > 0 || orphans > 0) { + store.log('cleanup.expired', { removed, orphans, ttlMs }); } - return { removed }; + return { removed, orphans }; + }, + + // Directories left behind by failed uploads (no matching job record) and older + // than the TTL. Without this, a simple error loop can fill the disk. + sweepOrphanDirs(ttlMs = DEFAULT_TTL_MS) { + let swept = 0; + let entries; + try { entries = fs.readdirSync(UPLOADS_DIR, { withFileTypes: true }); } + catch { return 0; } + + const now = Date.now(); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (store.jobs.has(entry.name)) continue; + const p = path.join(UPLOADS_DIR, entry.name); + let stat; + try { stat = fs.statSync(p); } catch { continue; } + if (now - stat.mtimeMs < ttlMs) continue; + try { fs.rmSync(p, { recursive: true, force: true }); swept++; } + catch { /* noop */ } + } + return swept; } }; diff --git a/src/services/jobService.js b/src/services/jobService.js index 77f7479..f2398ad 100644 --- a/src/services/jobService.js +++ b/src/services/jobService.js @@ -15,6 +15,13 @@ import { } from '../config/accessibilityCatalog.js'; const UPLOADS_DIR = path.resolve('uploads'); +const JOB_ID_RE = /^job_[0-9a-f-]{36}$/; + +export function isValidJobId(id) { + return typeof id === 'string' && JOB_ID_RE.test(id); +} + +export function getUploadsDir() { return UPLOADS_DIR; } function normalizeList(input, allowed, fallback) { const values = Array.isArray(input) ? input : (input ? [input] : []); @@ -32,7 +39,16 @@ export const jobService = { const jobDir = path.join(UPLOADS_DIR, jobId); await fs.promises.mkdir(jobDir, { recursive: true }); - const { fileInfo, fields } = await parseUpload(req, jobDir); + let fileInfo, fields; + try { + ({ fileInfo, fields } = await parseUpload(req, jobDir)); + } catch (err) { + // Upload failed — remove the orphan job dir so a simple error loop + // cannot fill the disk. + await fs.promises.rm(jobDir, { recursive: true, force: true }).catch(() => {}); + store.log('job.upload_failed', { jobId, error: err.message }); + throw err; + } let probeResult = { duration_ms: 0, has_video: false, has_audio: false, format: 'unknown', codec: 'unknown' }; try { diff --git a/src/services/persistence/postgresPersistence.js b/src/services/persistence/postgresPersistence.js index 655cad0..673113c 100644 --- a/src/services/persistence/postgresPersistence.js +++ b/src/services/persistence/postgresPersistence.js @@ -1,5 +1,6 @@ import { store } from '../../data/store.js'; import { exportState, importState } from './stateSerializer.js'; +import { runtimeConfig } from '../../config/runtime.js'; export class PostgresPersistence { constructor({ databaseUrl }) { @@ -66,6 +67,25 @@ export class PostgresPersistence { const row = result.rows[0]; store.log('persistence.postgres.snapshot', { snapshotId: row.id, reason }); + + // Prune old snapshots so the JSONB table doesn't grow forever. Default + // is keep last 20; configurable via SNAPSHOT_RETENTION. 0 disables prune. + const retention = runtimeConfig().snapshotRetention; + if (retention > 0) { + try { + const pruned = await this.client.query( + 'DELETE FROM app_state_snapshots WHERE id NOT IN (SELECT id FROM app_state_snapshots ORDER BY id DESC LIMIT $1)', + [retention] + ); + if (pruned.rowCount > 0) { + store.log('persistence.postgres.pruned', { deleted: pruned.rowCount, retention }); + } + } catch (err) { + // Prune failures are non-fatal — snapshot already saved. + store.log('persistence.postgres.prune_failed', { error: err.message }); + } + } + return { saved: true, snapshotId: row.id, createdAt: row.created_at, reason }; } diff --git a/src/services/persistence/stateSerializer.js b/src/services/persistence/stateSerializer.js index 47dafdd..99e26fd 100644 --- a/src/services/persistence/stateSerializer.js +++ b/src/services/persistence/stateSerializer.js @@ -8,12 +8,16 @@ function entriesToMap(entries) { return new Map(entries || []); } +// Snapshot a point-in-time copy of all state. structuredClone decouples the +// snapshot from live mutation: without it, the pipeline can keep writing to +// job.outputs while JSON.stringify / pg await, producing a half-written snapshot. export function exportState() { - return { + const raw = { jobs: mapToEntries(store.jobs), jobOutputs: mapToEntries(store.jobOutputs), auditLog: [...store.auditLog] }; + return structuredClone(raw); } export function importState(snapshot) { diff --git a/src/services/pipelineService.js b/src/services/pipelineService.js index d446584..2e0d790 100644 --- a/src/services/pipelineService.js +++ b/src/services/pipelineService.js @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; +import path from 'node:path'; import { store } from '../data/store.js'; import { createModelProvider } from './providers/modelProvider.js'; import { createTranslationProvider } from './providers/translationProvider.js'; @@ -8,7 +9,21 @@ import { runtimeConfig } from '../config/runtime.js'; export const jobEmitter = new EventEmitter(); jobEmitter.setMaxListeners(100); -const translationProvider = createTranslationProvider(); +// Translation provider is lazily constructed so runtime config (including +// explicit fallback policy) is read at job time, not at module import. +let _translationProvider = null; +function getTranslationProvider() { + if (!_translationProvider) _translationProvider = createTranslationProvider(); + return _translationProvider; +} + +class JobCancelledError extends Error { + constructor(jobId) { super(`Job ${jobId} was cancelled`); this.name = 'JobCancelledError'; } +} + +function assertJobAlive(jobId) { + if (!store.jobs.has(jobId)) throw new JobCancelledError(jobId); +} function toCaptionSegment(event) { return { @@ -52,13 +67,14 @@ function buildSignCards(signScript = []) { } async function buildCaptionTranslations(captions, outputLanguages) { + const provider = getTranslationProvider(); const map = {}; await Promise.all( outputLanguages.map(async (language) => { map[language] = await Promise.all( captions.map(async (segment) => ({ ...segment, - text: await translationProvider.translate(segment.text, language) + text: await provider.translate(segment.text, language) })) ); }) @@ -81,7 +97,10 @@ export async function runJobPipeline(jobId) { emitProgress(jobId, 0, 'Extracting audio...'); - const audioPath = job.file_path.replace(/\.[^.]+$/, '.wav'); + // Derive the WAV output path inside the job's own directory rather than + // by regex-rewriting job.file_path — defense in depth against a poisoned + // file_path (e.g. from a tampered snapshot) overwriting unrelated files. + const audioPath = path.join(path.dirname(job.file_path), 'audio.wav'); if (job.has_audio) { await mediaService.extractAudio(job.file_path, audioPath); } else { @@ -89,6 +108,7 @@ export async function runJobPipeline(jobId) { } emitProgress(jobId, 5, 'Splitting audio into chunks...'); + assertJobAlive(jobId); const chunks = await mediaService.chunkAudio(audioPath, config.chunkDurationMs); const totalChunks = chunks.length; @@ -98,6 +118,7 @@ export async function runJobPipeline(jobId) { const signScript = []; for (let i = 0; i < totalChunks; i++) { + assertJobAlive(jobId); const chunk = chunks[i]; const audio_base64 = await mediaService.readAsBase64(chunk.path); @@ -164,9 +185,15 @@ export async function runJobPipeline(jobId) { store.log('job.completed', { jobId, segments: captions.length }); } catch (err) { - job.status = 'error'; - job.error = err.message; - job.updatedAt = store.nowIso(); + if (err instanceof JobCancelledError) { + store.log('job.cancelled', { jobId }); + return; + } + if (job) { + job.status = 'error'; + job.error = err.message; + job.updatedAt = store.nowIso(); + } emitProgress(jobId, -1, err.message); jobEmitter.emit(`job:${jobId}`, { type: 'error', job_id: jobId, message: err.message }); diff --git a/src/services/providers/httpModelProvider.js b/src/services/providers/httpModelProvider.js index eac1460..6f9be5c 100644 --- a/src/services/providers/httpModelProvider.js +++ b/src/services/providers/httpModelProvider.js @@ -1,65 +1,78 @@ import { mockModelProvider } from './mockModelProvider.js'; +import { runtimeConfig } from '../../config/runtime.js'; +import { safeFetchJson, SsrfError } from '../../utils/safeFetch.js'; +import { store } from '../../data/store.js'; -const DEFAULT_TIMEOUT_MS = Number(process.env.MODEL_HTTP_TIMEOUT_MS || 4000); - -function withTimeout(timeoutMs) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - return { signal: controller.signal, clear: () => clearTimeout(timer) }; +function endpoint(baseUrl, path) { + return `${String(baseUrl || '').replace(/\/$/, '')}${path}`; } -async function postJson({ endpoint, payload, timeoutMs, headers = {} }) { - const timeout = withTimeout(timeoutMs); - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify(payload), - signal: timeout.signal - }); - if (!response.ok) throw new Error(`HTTP model provider failed: ${response.status}`); - return response.json(); - } finally { - timeout.clear(); +// Silent fallback to mock data on provider failure is dangerous: it serves +// plausible-looking fabricated captions when the upstream is actually down. +// Must be explicitly opted into via MODEL_FALLBACK_ON_ERROR=true, and every +// fallback is logged + tagged on the output object. +function fallbackOrThrow(runtime, name, err) { + store.log('provider.error', { provider: 'http', operation: name, error: err.message }); + if (runtime.modelFallbackOnError) { + store.log('provider.fallback', { from: 'http', to: 'mock', operation: name }); + return true; } + throw err; } -function endpoint(baseUrl, path) { - return `${String(baseUrl || '').replace(/\/$/, '')}${path}`; +function tagFallback(result, used) { + if (used && result && typeof result === 'object') result._provider = 'mock-fallback'; + return result; } export function createHttpModelProvider(config = {}) { - const baseUrl = config.base_url || process.env.MODEL_HTTP_BASE_URL; - const apiKey = config.api_key || process.env.MODEL_HTTP_API_KEY; - const timeoutMs = Number(config.timeout_ms || DEFAULT_TIMEOUT_MS); + const runtime = runtimeConfig(); + const baseUrl = config.base_url || runtime.modelHttpBaseUrl; + const apiKey = config.api_key || runtime.modelHttpApiKey; + const timeoutMs = Number(config.timeout_ms || runtime.modelHttpTimeoutMs); + const maxResponseBytes = runtime.httpProviderMaxResponseBytes; if (!baseUrl) { + store.log('provider.config', { provider: 'http', warning: 'MODEL_HTTP_BASE_URL missing, using mock' }); return { ...mockModelProvider, name: 'mock(http_missing_base_url)' }; } - const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; - - return { - name: 'http', - - async inferPerception(input) { - try { return await postJson({ endpoint: endpoint(baseUrl, '/v1/models/perception'), payload: input, timeoutMs, headers }); } - catch { return mockModelProvider.inferPerception(input); } - }, + // Allow plain http only if base URL hostname is localhost (common for whisper.cpp / Ollama setups). + const allowHttpLocalhost = (() => { + try { + const u = new URL(baseUrl); + return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1'; + } catch { return false; } + })(); - async inferAccessibility(input) { - try { return await postJson({ endpoint: endpoint(baseUrl, '/v1/models/accessibility'), payload: input, timeoutMs, headers }); } - catch { return mockModelProvider.inferAccessibility(input); } - }, + const headers = { 'Content-Type': 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) }; - async inferSignScript(input) { - try { return await postJson({ endpoint: endpoint(baseUrl, '/v1/models/sign-script'), payload: input, timeoutMs, headers }); } - catch { return mockModelProvider.inferSignScript(input); } - }, - - async transcribe(input) { - try { return await postJson({ endpoint: endpoint(baseUrl, '/v1/models/transcribe'), payload: input, timeoutMs, headers }); } - catch { return mockModelProvider.transcribe(input); } + async function call(path, payload, opName) { + try { + return await safeFetchJson(endpoint(baseUrl, path), { + method: 'POST', + headers, + body: JSON.stringify(payload), + timeoutMs, + maxResponseBytes, + allowHttpLocalhost + }); + } catch (err) { + if (err instanceof SsrfError) { + store.log('provider.ssrf_blocked', { provider: 'http', operation: opName, error: err.message }); + throw err; + } + const didFallback = fallbackOrThrow(runtime, opName, err); + if (didFallback) return tagFallback(await mockModelProvider[opName](payload), true); + throw err; } + } + + return { + name: 'http', + async inferPerception(input) { return call('/v1/models/perception', input, 'inferPerception'); }, + async inferAccessibility(input){ return call('/v1/models/accessibility', input, 'inferAccessibility'); }, + async inferSignScript(input) { return call('/v1/models/sign-script', input, 'inferSignScript'); }, + async transcribe(input) { return call('/v1/models/transcribe', input, 'transcribe'); } }; } diff --git a/src/services/providers/translationProvider.js b/src/services/providers/translationProvider.js index 154a907..15cae36 100644 --- a/src/services/providers/translationProvider.js +++ b/src/services/providers/translationProvider.js @@ -1,13 +1,9 @@ import { runtimeConfig } from '../../config/runtime.js'; +import { safeFetchJson, SsrfError } from '../../utils/safeFetch.js'; +import { store } from '../../data/store.js'; const DEFAULT_TIMEOUT_MS = Number(process.env.TRANSLATION_HTTP_TIMEOUT_MS || 5000); -function withTimeout(timeoutMs) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - return { signal: controller.signal, clear: () => clearTimeout(timer) }; -} - function createBoundedCache() { const rt = runtimeConfig(); const ttlMs = rt.translationCacheTtlMs; @@ -39,9 +35,19 @@ const mockTranslationProvider = { } }; +function fallbackText(targetLanguage, text) { return `[${targetLanguage}] ${text}`; } + +function isLocalhostHost(urlStr) { + try { const u = new URL(urlStr); return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1'; } + catch { return false; } +} + function createLibreTranslateProvider({ baseUrl, apiKey }) { const cache = createBoundedCache(); const url = `${baseUrl.replace(/\/$/, '')}/translate`; + const allowHttpLocalhost = isLocalhostHost(baseUrl); + const runtime = runtimeConfig(); + const maxResponseBytes = runtime.httpProviderMaxResponseBytes; return { name: 'libretranslate', @@ -54,23 +60,32 @@ function createLibreTranslateProvider({ baseUrl, apiKey }) { const cached = cache.get(cacheKey); if (cached !== undefined) return cached; - const t = withTimeout(DEFAULT_TIMEOUT_MS); try { const body = { q: text, source, target, format: 'text' }; if (apiKey) body.api_key = apiKey; - const res = await fetch(url, { + const data = await safeFetchJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - signal: t.signal + timeoutMs: DEFAULT_TIMEOUT_MS, + maxResponseBytes, + allowHttpLocalhost }); - if (!res.ok) throw new Error(`LibreTranslate error: ${res.status}`); - const data = await res.json(); const result = data.translatedText || text; cache.set(cacheKey, result); return result; - } catch { return `[${targetLanguage}] ${text}`; } - finally { t.clear(); } + } catch (err) { + if (err instanceof SsrfError) { + store.log('provider.ssrf_blocked', { provider: 'libretranslate', error: err.message }); + throw err; + } + store.log('provider.error', { provider: 'libretranslate', error: err.message }); + if (runtime.modelFallbackOnError) { + store.log('provider.fallback', { from: 'libretranslate', to: 'passthrough' }); + return fallbackText(targetLanguage, text); + } + throw err; + } } }; } @@ -78,6 +93,8 @@ function createLibreTranslateProvider({ baseUrl, apiKey }) { function createDeeplProvider({ apiKey, baseUrl }) { const cache = createBoundedCache(); const apiUrl = `${(baseUrl || 'https://api-free.deepl.com').replace(/\/$/, '')}/v2/translate`; + const runtime = runtimeConfig(); + const maxResponseBytes = runtime.httpProviderMaxResponseBytes; return { name: 'deepl', @@ -89,21 +106,29 @@ function createDeeplProvider({ apiKey, baseUrl }) { const cached = cache.get(cacheKey); if (cached !== undefined) return cached; - const t = withTimeout(DEFAULT_TIMEOUT_MS); try { - const res = await fetch(apiUrl, { + const data = await safeFetchJson(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DeepL-Auth-Key ${apiKey}` }, body: JSON.stringify({ text: [text], target_lang: target }), - signal: t.signal + timeoutMs: DEFAULT_TIMEOUT_MS, + maxResponseBytes }); - if (!res.ok) throw new Error(`DeepL error: ${res.status}`); - const data = await res.json(); const result = data.translations?.[0]?.text || text; cache.set(cacheKey, result); return result; - } catch { return `[${targetLanguage}] ${text}`; } - finally { t.clear(); } + } catch (err) { + if (err instanceof SsrfError) { + store.log('provider.ssrf_blocked', { provider: 'deepl', error: err.message }); + throw err; + } + store.log('provider.error', { provider: 'deepl', error: err.message }); + if (runtime.modelFallbackOnError) { + store.log('provider.fallback', { from: 'deepl', to: 'passthrough' }); + return fallbackText(targetLanguage, text); + } + throw err; + } } }; } diff --git a/src/services/uiService.js b/src/services/uiService.js index a952b36..15fea72 100644 --- a/src/services/uiService.js +++ b/src/services/uiService.js @@ -182,9 +182,64 @@ const SHARED_STYLES = ` } `; +// Minimal, dependency-free markdown subset: headings, paragraphs, code spans, +// and links. Anything a policy page needs. Everything is escaped first, so +// rendered output cannot introduce script vectors. +function renderMarkdownLite(raw) { + const escaped = escapeHtml(raw); + const lines = escaped.split(/\r?\n/); + const out = []; + let inPara = false; + const close = () => { if (inPara) { out.push('

'); inPara = false; } }; + for (const line of lines) { + const h = line.match(/^(#{1,6})\s+(.*)$/); + if (h) { + close(); + const lvl = h[1].length; + out.push(`${h[2]}`); + continue; + } + if (!line.trim()) { close(); continue; } + if (!inPara) { out.push('

'); inPara = true; } else { out.push(' '); } + const withInline = line + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^\s)]+)\)/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1'); + out.push(withInline); + } + close(); + return out.join(''); +} + export const uiService = { escapeHtml, + buildDocPage(title, markdown) { + return ` + + + + + ${escapeHtml(title)} — Accessibility Lite + ${OG_META} + + + +

+

← Home

+
${renderMarkdownLite(markdown)}
+
+ ${FOOTER_HTML} + +`; + }, + buildErrorPage(statusCode, title, message, { autoRefresh = 0 } = {}) { const refreshTag = autoRefresh > 0 ? `` : ''; return ` diff --git a/src/utils/http.js b/src/utils/http.js index 1c24e83..2e644af 100644 --- a/src/utils/http.js +++ b/src/utils/http.js @@ -1,4 +1,5 @@ import { URL } from 'node:url'; +import { runtimeConfig } from '../config/runtime.js'; export function sendJson(res, statusCode, payload) { const body = JSON.stringify(payload); @@ -18,11 +19,30 @@ export function sendHtml(res, statusCode, html) { res.end(body); } -export async function parseJsonBody(req) { +export class PayloadTooLargeError extends Error { + constructor(limit) { + super(`Request body exceeds ${limit} bytes`); + this.statusCode = 413; + this.limit = limit; + } +} + +export async function parseJsonBody(req, { maxBytes } = {}) { + const limit = maxBytes ?? runtimeConfig().maxJsonBodyBytes; const chunks = []; + let total = 0; + let overflowed = false; for await (const chunk of req) { - chunks.push(chunk); + total += chunk.length; + if (total > limit) { + overflowed = true; + // Drain the rest silently rather than destroy the socket — destroying + // here would prevent the handler from sending a 413 response cleanly. + } else if (!overflowed) { + chunks.push(chunk); + } } + if (overflowed) throw new PayloadTooLargeError(limit); const raw = Buffer.concat(chunks).toString('utf8'); if (!raw) return {}; try { diff --git a/src/utils/safeFetch.js b/src/utils/safeFetch.js new file mode 100644 index 0000000..3ad4636 --- /dev/null +++ b/src/utils/safeFetch.js @@ -0,0 +1,128 @@ +import dns from 'node:dns/promises'; +import net from 'node:net'; +import { URL } from 'node:url'; + +// RFC1918 / loopback / link-local / unique-local-addresses / CGNAT. +// Hosts resolving to any of these are refused — prevents SSRF against +// cloud-metadata endpoints (169.254.169.254), container-local services, etc. +function isPrivateIPv4(ip) { + const parts = ip.split('.').map(Number); + if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return false; + const [a, b] = parts; + if (a === 10) return true; + if (a === 127) return true; + if (a === 0) return true; + if (a === 169 && b === 254) return true; // link-local, includes AWS/GCP metadata + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + if (a >= 224) return true; // multicast / reserved + return false; +} + +function isPrivateIPv6(ip) { + const lower = ip.toLowerCase(); + if (lower === '::1' || lower === '::') return true; + if (lower.startsWith('fe80:')) return true; // link-local + if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // ULA + if (lower.startsWith('ff')) return true; // multicast + // IPv4-mapped + const mapped = lower.match(/^::ffff:([0-9.]+)$/); + if (mapped) return isPrivateIPv4(mapped[1]); + return false; +} + +function isPrivate(ip) { + if (net.isIPv4(ip)) return isPrivateIPv4(ip); + if (net.isIPv6(ip)) return isPrivateIPv6(ip); + return true; // refuse what we cannot classify +} + +export class SsrfError extends Error { + constructor(message) { super(message); this.name = 'SsrfError'; } +} + +/** + * Resolve a URL's hostname and refuse private / loopback / metadata IPs. + * Accepts http:// for localhost only; all other hosts must be https. + * Returns the validated URL string on success. + */ +export async function assertSafeUrl(rawUrl, { allowHttpLocalhost = false } = {}) { + let parsed; + try { parsed = new URL(rawUrl); } + catch { throw new SsrfError(`Invalid URL: ${rawUrl}`); } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new SsrfError(`Unsupported protocol: ${parsed.protocol}`); + } + + const host = parsed.hostname; + const isLocalhostName = host === 'localhost' || host === 'localhost.localdomain'; + + if (parsed.protocol === 'http:' && !(allowHttpLocalhost && isLocalhostName)) { + throw new SsrfError(`Refusing plain http to non-localhost host: ${host}`); + } + + // Literal IP case + if (net.isIP(host)) { + if (isPrivate(host) && !allowHttpLocalhost) { + throw new SsrfError(`Refusing request to private/loopback IP: ${host}`); + } + return parsed.toString(); + } + + // DNS resolution — refuse DNS-rebinding style attacks where a public + // hostname resolves to a private address + const addrs = await dns.lookup(host, { all: true, verbatim: true }); + if (!addrs.length) throw new SsrfError(`DNS lookup returned no addresses for: ${host}`); + for (const { address } of addrs) { + if (isPrivate(address) && !(allowHttpLocalhost && isLocalhostName)) { + throw new SsrfError(`Hostname ${host} resolves to private/loopback IP: ${address}`); + } + } + return parsed.toString(); +} + +/** + * Fetch wrapper that (1) validates URL against SSRF rules, (2) caps response + * body by byte count, (3) enforces a hard timeout. Returns parsed JSON. + */ +export async function safeFetchJson(url, { method = 'GET', headers = {}, body, timeoutMs = 5000, maxResponseBytes = 10 * 1024 * 1024, allowHttpLocalhost = false } = {}) { + await assertSafeUrl(url, { allowHttpLocalhost }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { method, headers, body, signal: controller.signal, redirect: 'error' }); + if (!res.ok) { + const snippet = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status}: ${snippet.slice(0, 200)}`); + } + + const declared = Number(res.headers.get('content-length') || 0); + if (declared > maxResponseBytes) { + throw new Error(`Response content-length ${declared} exceeds cap ${maxResponseBytes}`); + } + + const reader = res.body?.getReader(); + if (!reader) return {}; + const chunks = []; + let total = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + total += value.length; + if (total > maxResponseBytes) { + try { reader.cancel(); } catch { /* noop */ } + throw new Error(`Response exceeded cap ${maxResponseBytes} bytes`); + } + chunks.push(value); + } + const buf = Buffer.concat(chunks.map(c => Buffer.from(c))); + if (!buf.length) return {}; + return JSON.parse(buf.toString('utf8')); + } finally { + clearTimeout(timer); + } +} diff --git a/src/utils/upload.js b/src/utils/upload.js index eea70ae..b35930e 100644 --- a/src/utils/upload.js +++ b/src/utils/upload.js @@ -19,6 +19,37 @@ const EXT_MAP = { 'audio/x-wav': '.wav' }; +// Magic-byte prefixes. The client-declared Content-Type is attacker-controlled; +// we refuse the upload if the first bytes don't match a known media container. +// This is defense-in-depth ahead of ffprobe (which has had exploitable CVEs). +function magicBytesMatch(head, mimeType) { + if (head.length < 12) return false; + const isMp4Family = head.slice(4, 8).toString('ascii') === 'ftyp'; + switch (mimeType) { + case 'video/mp4': + case 'audio/mp4': + return isMp4Family; + case 'video/quicktime': + return isMp4Family; // .mov uses ftyp qt /moov atoms + case 'video/webm': + // Matroska/WebM: EBML header 1A 45 DF A3 + return head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3; + case 'audio/wav': + case 'audio/x-wav': + return head.slice(0, 4).toString('ascii') === 'RIFF' && head.slice(8, 12).toString('ascii') === 'WAVE'; + case 'audio/ogg': + return head.slice(0, 4).toString('ascii') === 'OggS'; + case 'audio/mpeg': { + // ID3v2 tag + if (head.slice(0, 3).toString('ascii') === 'ID3') return true; + // MPEG frame sync 0xFFEx + return head[0] === 0xff && (head[1] & 0xe0) === 0xe0; + } + default: + return false; + } +} + export function parseUpload(req, jobDir) { return new Promise((resolve, reject) => { const config = runtimeConfig(); @@ -30,17 +61,24 @@ export function parseUpload(req, jobDir) { }); let fileInfo = null; + let fileError = null; + let finished = false; + let pendingDrain = null; // resolves when the writeStream finishes const fields = {}; - busboy.on('field', (name, value) => { - fields[name] = value; - }); + function fail(err) { + if (fileError) return; + fileError = err; + reject(err); + } + + busboy.on('field', (name, value) => { fields[name] = value; }); busboy.on('file', (fieldname, stream, info) => { const { filename, mimeType } = info; if (!ALLOWED_MIME_TYPES.has(mimeType)) { stream.resume(); - reject(new Error(`Unsupported file type: ${mimeType}. Accepted: ${[...ALLOWED_MIME_TYPES].join(', ')}`)); + fail(new Error(`Unsupported file type: ${mimeType}. Accepted: ${[...ALLOWED_MIME_TYPES].join(', ')}`)); return; } @@ -48,40 +86,65 @@ export function parseUpload(req, jobDir) { const destPath = path.join(jobDir, `original${ext}`); const writeStream = fs.createWriteStream(destPath); let bytes = 0; + let headBuf = Buffer.alloc(0); + let sniffed = false; stream.on('data', (chunk) => { bytes += chunk.length; + if (!sniffed) { + headBuf = Buffer.concat([headBuf, chunk], Math.min(headBuf.length + chunk.length, 64)); + if (headBuf.length >= 12) { + sniffed = true; + if (!magicBytesMatch(headBuf, mimeType)) { + stream.unpipe(writeStream); + writeStream.destroy(); + stream.resume(); + fail(new Error(`File contents do not match declared type ${mimeType}`)); + return; + } + } + } }); stream.pipe(writeStream); stream.on('limit', () => { writeStream.destroy(); - reject(new Error(`File exceeds maximum size of ${config.maxUploadMb}MB`)); + fail(new Error(`File exceeds maximum size of ${config.maxUploadMb}MB`)); }); - writeStream.on('finish', () => { - fileInfo = { - original_filename: filename, - mime_type: mimeType, - file_path: destPath, - file_size_bytes: bytes, - ext - }; + pendingDrain = new Promise((res) => { + writeStream.on('finish', () => { + if (!sniffed) { + fail(new Error('File too short to validate')); + } else if (!fileError) { + fileInfo = { + original_filename: filename, + mime_type: mimeType, + file_path: destPath, + file_size_bytes: bytes, + ext + }; + } + res(); + }); + writeStream.on('error', (err) => { fail(err); res(); }); }); - - writeStream.on('error', (err) => reject(err)); }); - busboy.on('finish', () => { + busboy.on('finish', async () => { + finished = true; + if (pendingDrain) await pendingDrain; + if (fileError) return; if (!fileInfo) { - reject(new Error('No file uploaded')); + fail(new Error('No file uploaded')); return; } resolve({ fileInfo, fields }); }); - busboy.on('error', (err) => reject(err)); + busboy.on('error', (err) => fail(err)); + req.on('aborted', () => fail(new Error('Upload aborted by client'))); req.pipe(busboy); }); } diff --git a/test/jobService.test.js b/test/jobService.test.js index 5545ea3..a981d45 100644 --- a/test/jobService.test.js +++ b/test/jobService.test.js @@ -2,14 +2,17 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; -let server; +// Static imports would run before any module code here, so env vars set at +// the top of this file wouldn't reach src/server.js. Dynamic import inside +// the `before` hook is the reliable way to parameterise the test server. let baseUrl; +let server; +let start; -function request(method, path, body = null) { +function request(method, path, body = null, extraHeaders = {}) { return new Promise((resolve, reject) => { const url = new URL(path, baseUrl); - const options = { method, hostname: url.hostname, port: url.port, path: url.pathname + url.search }; - const headers = {}; + const headers = { ...extraHeaders }; let bodyStr = null; if (body && typeof body === 'object' && !(body instanceof Buffer)) { @@ -18,7 +21,7 @@ function request(method, path, body = null) { headers['Content-Length'] = Buffer.byteLength(bodyStr); } - options.headers = headers; + const options = { method, hostname: url.hostname, port: url.port, path: url.pathname + url.search, headers }; const req = http.request(options, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); @@ -47,7 +50,7 @@ function multipartUpload(path, filename, content, mimeType) { content, `\r\n--${boundary}--\r\n` ]; - const body = Buffer.concat(bodyParts.map((p) => Buffer.from(p))); + const body = Buffer.concat(bodyParts.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p)))); const options = { method: 'POST', @@ -78,9 +81,17 @@ function multipartUpload(path, filename, content, mimeType) { describe('Accessibility Lite API', () => { before(async () => { - const app = await import('../src/server.js'); - await new Promise((resolve) => setTimeout(resolve, 500)); - baseUrl = 'http://localhost:3000'; + process.env.AUTH_DISABLED = 'true'; + process.env.PORT = process.env.PORT || '3100'; + const mod = await import('../src/server.js'); + start = mod.start; + server = mod.server; + await start(); + baseUrl = `http://localhost:${process.env.PORT}`; + }); + + after(async () => { + await new Promise((resolve) => server.close(resolve)); }); it('GET /health returns ok', async () => { @@ -96,6 +107,25 @@ describe('Accessibility Lite API', () => { assert.ok(res.data.includes('Accessibility Lite')); }); + it('GET /cookies returns cookie policy', async () => { + const res = await request('GET', '/cookies'); + assert.equal(res.status, 200); + assert.ok(res.data.includes('Cookie policy')); + }); + + it('GET /privacy returns privacy notice', async () => { + const res = await request('GET', '/privacy'); + assert.equal(res.status, 200); + assert.ok(res.data.includes('Privacy notice')); + }); + + it('sets security headers on all responses', async () => { + const res = await request('GET', '/'); + assert.equal(res.headers['x-content-type-options'], 'nosniff'); + assert.equal(res.headers['x-frame-options'], 'SAMEORIGIN'); + assert.ok(res.headers['content-security-policy']); + }); + it('GET /v1/catalog returns accessibility catalog', async () => { const res = await request('GET', '/v1/catalog'); assert.equal(res.status, 200); @@ -103,23 +133,42 @@ describe('Accessibility Lite API', () => { assert.ok(res.data.output_languages.includes('en-US')); assert.ok(Array.isArray(res.data.sign_languages)); assert.ok(res.data.sign_languages.includes('ASL')); - assert.ok(Array.isArray(res.data.sign_overlay_themes)); - assert.equal(res.data.sign_overlay_themes.length, 6); }); - it('GET /v1/jobs/:id returns 404 for missing job', async () => { + it('rejects malformed job id as 404 (no path traversal)', async () => { + const res = await request('GET', '/v1/jobs/..%2Fetc%2Fpasswd'); + assert.equal(res.status, 404); + }); + + it('rejects job id that does not match pattern', async () => { const res = await request('GET', '/v1/jobs/job_nonexistent'); assert.equal(res.status, 404); }); - it('rejects unsupported file type', async () => { + it('rejects unsupported file type at upload', async () => { const res = await multipartUpload('/v1/jobs', 'test.txt', 'hello', 'text/plain'); assert.equal(res.status, 400); assert.ok(res.data.error.includes('Unsupported file type')); }); - it('GET /v1/jobs/:id/captions.vtt returns 404 for missing job', async () => { - const res = await request('GET', '/v1/jobs/job_nonexistent/captions.vtt'); + it('rejects mime/content mismatch (magic-byte sniff)', async () => { + // Declared as audio/wav but bytes are not a RIFF WAVE header. + const res = await multipartUpload('/v1/jobs', 'fake.wav', 'Not a real WAV file payload', 'audio/wav'); + assert.equal(res.status, 400); + assert.ok(/do not match declared type|Unsupported|too short/.test(res.data.error)); + }); + + it('GET /v1/jobs/:id/captions.vtt returns 404 for missing (valid-shape) job', async () => { + const fakeId = 'job_00000000-0000-0000-0000-000000000000'; + const res = await request('GET', `/v1/jobs/${fakeId}/captions.vtt`); assert.equal(res.status, 404); }); + + it('rejects oversize JSON bodies with 413', async () => { + // Build a body > 1MB + const big = { filler: 'x'.repeat(1024 * 1024 + 10) }; + const fakeId = 'job_00000000-0000-0000-0000-000000000000'; + const res = await request('POST', `/v1/jobs/${fakeId}/process`, big); + assert.equal(res.status, 413); + }); });