diff --git a/infosec/.gitignore b/infosec/.gitignore deleted file mode 100644 index fbca225..0000000 --- a/infosec/.gitignore +++ /dev/null @@ -1 +0,0 @@ -results/ diff --git a/infosec/README.md b/infosec/README.md deleted file mode 100644 index ec27772..0000000 --- a/infosec/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Security assessment — sa-external-data-store - -Offensive security tests and reports for this service. **Does not modify application source** under `src/`. - -## Contents - -| Path | Purpose | -|------|---------| -| [SECURITY_REPORT.md](./SECURITY_REPORT.md) | Executive summary, findings, severity, evidence | -| [RECOMMENDATIONS.md](./RECOMMENDATIONS.md) | Prioritized remediation backlog | -| [tests/](./tests/) | Runnable probes (shell + static analysis) | -| [scripts/run-all.sh](./scripts/run-all.sh) | Run all tests and write `results/` artifacts | - -## Prerequisites - -- Application running locally (default `http://localhost:3005`) -- For signed-URL tests: `LOCAL_SIGN_SECRET_KEY` exported if not using README default -- `curl`, `openssl`, `python3`, `node`, `npx ts-node` - -## Quick start - -```bash -# From repo root -export BASE_URL="${BASE_URL:-http://localhost:3005}" -export LOCAL_SIGN_SECRET_KEY="${LOCAL_SIGN_SECRET_KEY:-your-sign-secret}" - -chmod +x infosec/scripts/run-all.sh infosec/tests/*.sh -./infosec/scripts/run-all.sh -``` - -Results land in `infosec/results/` (gitignored). - -## Scope - -- Unauthenticated: `/health`, `/storage/fileSigned` -- Authenticated (requires valid `x-sa-access-token`): `/annotation`, `/storage/file`, `/storage/signedUrl`, `/check` -- Static review of auth, path handling, S3/LOCAL backends, CORS, logging - -## Out of scope (unless you extend) - -- AWS account / bucket IAM review -- SuperAnnotate API penetration -- Load / DoS at scale -- Supply chain / dependency CVE audit (run `npm audit` separately) - -## Responsible use - -Run only against environments you own or are authorized to test. Do not commit tokens or secrets into this folder. diff --git a/infosec/RECOMMENDATIONS.md b/infosec/RECOMMENDATIONS.md deleted file mode 100644 index eea6beb..0000000 --- a/infosec/RECOMMENDATIONS.md +++ /dev/null @@ -1,76 +0,0 @@ -# Security recommendations (prioritized) - -Backlog from the 2026-06-03 assessment, re-validated 2026-06-04 (manifest redesign) -and 2026-06-08 (access-map flow + remediations). See [SECURITY_REPORT.md](./SECURITY_REPORT.md) for evidence and severity. - ---- - -## Done (verified) - -- ~~**Path traversal jail (INF-001)**~~ — `items/`, `files/`, `access_maps/` roots jailed; capability HMAC compared in constant time. -- ~~**S3 `/storage/fileSigned` bypass (INF-002)**~~ — uses `validateFilesSignature` (`false` in S3); always-true `validateSignature` unreachable; manifest/files flow `501` under S3. -- ~~**IDOR `sa-file-path` (INF-003)**~~ — header branch removed; path derived from authoritative `getItem`; item name validated as a safe segment. -- ~~**Signing secret (INF-004)**~~ — rotated to a long random value; `.env` gitignored/untracked. -- ~~**CORS (INF-005)**~~ — restricted to `https://*.superannotate.com` via anchored regex; verified live (suffix/prefix/HTTP bypasses blocked). -- ~~**Signed-URL host (INF-006)**~~ — built from `PUBLIC_HOST`/`PUBLIC_PROTOCOL` (`Config.publicBaseUrl()`); request `Host` no longer trusted. -- ~~**SA host config (INF-011)**~~ — env-driven (`SA_ITEM_API_HOST`/`SA_USER_API_HOST`), required in prod, resolved once at startup. -- ~~**Invalid token → 401 (INF-012)**~~ — typed `SaApiError` carries status; both middlewares map on status; verified live. -- ~~**Debug logging regression (INF-013)**~~ — `console.log`s removed from `pathSafety.ts` / `annotationRouter.ts`. -- ~~**Security headers + download hardening (INF-007)**~~ — `helmet()` + `app.disable('x-powered-by')`; `fileSigned` always `nosniff` with a default-deny inline/attachment policy (SVG/HTML/unknown → octet-stream + attachment); `isSafeSegment` rejects control chars. Verified live + unit tests. - -## Accepted (no fix planned) - -- **Residual logging (INF-008)** — Low; remaining `console.*` calls log no credentials/user-controlled values on any hot path. Conditions: prod sets `NODE_ENV=production`; never log full headers/requests. Revisit if a structured logger is adopted. - ---- - -## P0 — Do immediately - -_None outstanding._ (Production config must still set `NODE_ENV=production`, `SA_*_API_HOST`, `PUBLIC_HOST`/`PUBLIC_PROTOCOL` — all fail-fast if missing.) - ---- - -## P1 — Next sprint (all remaining items are Low severity) - -1. **Rate limiting (INF-009)** — `express-rate-limit` on `/storage/fileSigned` (and auth endpoints) per IP. -2. **Upload cap (INF-010)** — streaming size limit on `POST /annotation/`. - ---- - -## P2 — Hardening - -5. **Clock skew** — allow small negative skew on `expires`; document/short max TTL for capability URLs. -6. **Access-map uniqueness** — confirm SA item names are unique per project (access maps are keyed `{team}/{project}/`); collisions would share an allowlist. - ---- - -## P3 — Process & assurance - -7. **Add regression tests** mirroring `infosec/tests/02-path-traversal-fileSigned.sh` to the Jest suite (partly covered by `pathSafety.test.ts`). -8. **Secrets scanning** — gitleaks in CI; block README placeholder secrets in deploy templates. -9. **Dependency audit** — `npm audit` in CI, fail on high/critical. -10. **Threat model doc** — trust boundaries: SA IdP, this service, local disk / S3, public exposure. -11. **Authorized regression test** — `SA_ACCESS_TOKEN` from a CI secret to exercise the access-map happy path. - ---- - -## Deployment checklist - -- [ ] `NODE_ENV=production` set (gates dev request logger + dev host fallbacks) -- [ ] `SA_ITEM_API_HOST` / `SA_USER_API_HOST` set to prod SuperAnnotate -- [ ] `PUBLIC_HOST` / `PUBLIC_PROTOCOL` set (signed-URL prefix) -- [ ] `DATA_STORE` intentional for environment -- [ ] `LOCAL_SIGN_SECRET_KEY` rotated and in a secret manager -- [ ] `LOCAL_STORAGE_PATH` permissions minimal (service user only) -- [ ] Service not exposed publicly without API gateway auth / WAF -- [ ] Logs do not contain `x-sa-access-token` -- [ ] `./infosec/scripts/run-all.sh` passes on staging RC - ---- - -## Remaining quick wins (< 1 day effort) - -| Item | Effort | Impact | -|------|--------|--------| -| `express-rate-limit` on `fileSigned` (INF-009) | Small | Low | -| Body-size cap on `POST /annotation/` (INF-010) | Small | Low | diff --git a/infosec/SECURITY_REPORT.md b/infosec/SECURITY_REPORT.md deleted file mode 100644 index 1cbb598..0000000 --- a/infosec/SECURITY_REPORT.md +++ /dev/null @@ -1,326 +0,0 @@ -# Security assessment report - -**Application:** sa-external-data-store -**Assessment date:** 2026-06-03 -**Re-validations:** 2026-06-04 (item-manifest redesign), 2026-06-08 (access-map flow + remediations) -**Assessor role:** Internal offensive review (black-box + source-assisted static analysis) -**Scope:** API surface in `src/`, default port `3005`, LOCAL and S3 configuration modes -**Artifacts:** `infosec/` (tests, results, this report) - -> **2026-06-08 re-validation summary.** Since the last review the storage flow moved to -> an **owner-curated access map**: `GET /storage/` resolves the item via SuperAnnotate -> (`getItem`) then reads `access_maps/{teamId}/{projectId}/.json` (project-scoped) -> and signs only the files it declares. Multiple findings were remediated and verified: -> **INF-004** (secret rotated), **INF-005** (CORS now restricted to `https://*.superannotate.com`), -> **INF-006** (signed-URL host from `PUBLIC_HOST`/`publicBaseUrl`, not the request Host), -> **INF-011** (SA hosts env-driven, prod-required), **INF-012** (typed `SaApiError` → invalid -> token returns **401**, verified live). A regression introduced during the access-map work -> (**INF-013**, debug `console.log`s) was fixed. **INF-007** (security headers + stored-content -> rendering) was then remediated with `helmet`, `x-powered-by` disabled, and a default-deny -> inline/attachment policy on `fileSigned`; **INF-008** (residual logging) was **risk-accepted**. -> All 7 infosec suites pass (`infosec/results/latest`); 101/101 unit tests pass. -> **Open:** INF-009, INF-010 (both Low). -> -> **2026-06-04 re-validation summary.** Storage redesigned to a manifest model: -> `PathValidatorMiddleware` resolves the on-disk location from SuperAnnotate (`getItem`) -> instead of client paths; `/storage/file` and `/storage/signedUrl` removed; `/storage/fileSigned` -> jailed to the `files/` root with a constant-time HMAC capability. Remediated INF-001, INF-002, -> INF-003; INF-008 partially. Tests `01` and `05` updated to the new surface. - ---- - -## Executive summary - -The original critical issues (path traversal, S3 signature bypass, `sa-file-path` IDOR) are **remediated**: the storage path is derived from an authoritative SuperAnnotate `getItem` lookup (never from client input), the signable file allowlist comes from an **owner-curated access map**, and `/storage/fileSigned` is a constant-time HMAC capability jailed to the `files/` root. Auth error mapping, CORS, signed-URL host, and SA host configuration have all been hardened and verified. - -Remaining risk is **operational hardening**, all Low severity: - -1. **No rate limiting (INF-009)** on the unauthenticated `fileSigned` endpoint. -2. **No upload size cap (INF-010)** on `POST /annotation/`. - -INF-008 (residual logging) has been **risk-accepted** (Low; no sensitive values logged on any hot path). All other findings are remediated. - -Live probes against `http://localhost:3005` confirm: traversal/forged/expired capability requests → `401`; legacy endpoints → `404`; auth gates → `401`; CORS rejects non-`superannotate.com` origins; security headers present and `X-Powered-By` removed. No file-read or auth bypass achieved. - ---- - -## Methodology - -| Phase | Activities | -|-------|------------| -| Recon | README, route map, middleware order, env vars | -| Static review | Auth, path resolution, signing, S3/LOCAL repositories, error handling | -| Dynamic probes | Scripts in `infosec/tests/*.sh` against running dev server | -| Regression | Original security-engineer curl PoC + encoding variants | - -**Evidence artifacts:** run `./infosec/scripts/run-all.sh` → `infosec/results/latest/*.log` - ---- - -## Attack surface - -| Endpoint | Auth | Storage impact | -|----------|------|----------------| -| `GET /health` | None | Info | -| `GET /storage/` | SA token + `getItem` | Reads access map; mints signed URLs for declared files | -| `GET /storage/fileSigned` | HMAC capability (LOCAL), jailed to `files/` | File read | -| `GET/POST /annotation/` | SA token + `getItem` (+ read/write perm) | Read/write `_annotation.json` | -| `GET /check` | SA token | Health + storage connectivity | - -Removed in the redesign: `GET /storage/file`, `GET /storage/signedUrl` (now `404`). - ---- - -## Findings - -### INF-001 — Path traversal via signed URL (LOCAL) — **Remediated / retest** - -| Field | Value | -|-------|--------| -| **Severity** | Critical (pre-fix) → **Low** (retest with jail) | -| **CWE** | CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) | -| **Affected** | `GET /storage/fileSigned`, `LocalRepository.getFilePath` | - -**Description:** User-controlled `path` was joined into the filesystem without a jail, allowing `../` chains to read arbitrary local files if the HMAC was valid. - -**Current controls:** `resolveItemsFilePath` / `assertSafeRelativeItemsPath` in `src/utils/pathSafety.ts`; checks in `localRepository`, `storageRouter`, and `pathValidatorMiddleware`. - -**Current controls (2026-06-08):** signed downloads are jailed to the `files/` root via `resolveFilesPath`; `validateFilesSignature` returns `false` for any path that escapes the jail (→ `401`). The `items/` and `access_maps/` roots are jailed separately, and the SA item name + every access-map `files[]` entry must pass `isSafeSegment`. - -**Dynamic retest (2026-06-08):** PoC with `../../../../../../etc/passwd` + forged/expired/garbage signatures all return **HTTP 401** — never `200` with file content. - ---- - -### INF-002 — `/storage/fileSigned` signature bypass in S3 mode — **Remediated (2026-06-04)** - -| Field | Value | -|-------|--------| -| **Severity** | High (pre-fix) → **Low** (retest) | -| **CWE** | CWE-306 (Missing Authentication for Critical Function) | -| **Location** | `src/repository/s3Repository.ts`, `src/routes/storageRouter.ts` | - -**Description (original):** In S3 mode, any request to `/storage/fileSigned` with arbitrary `path/expires/signature` passed validation because `validateSignature` returned `true`. - -**Fix:** `/storage/fileSigned` now calls `validateFilesSignature`, which is **not** the always-true method. In S3 mode `S3Repository.validateFilesSignature` returns `false` (no HMAC scheme), so redemption yields `401`. The old always-true `validateSignature` is no longer exposed by the repository facade, so it is unreachable from any route. The manifest/files flow throws `501 NOT_SUPPORTED` under S3. - -**Residual:** If S3 needs signed delivery, wire it to AWS presigned URLs from `getSignedUrl`; do not re-expose `validateSignature`. - ---- - -### INF-003 — IDOR via `sa-file-path` header — **Remediated (2026-06-04)** - -| Field | Value | -|-------|--------| -| **Severity** | High (pre-fix) → **Low** (retest) | -| **CWE** | CWE-639 (Authorization Bypass Through User-Controlled Key) | -| **Location** | `src/middleware/pathValidatorMiddleware.ts` | - -**Description (original):** The `sa-file-path` branch built a storage path from a client-supplied string after only validating token identity — no per-resource authorization. - -**Fix:** The `sa-file-path` branch was **removed**. All routes now require `team/project/folder/item` and resolve the on-disk location from an authoritative SuperAnnotate `getItem` call (which 4xx's for items the caller cannot access). The resolved item name is validated as a single safe segment (`isSafeSegment`) before being used as a path component. The client can no longer supply or influence the path. - -**Retest:** `infosec/tests/05-header-and-input-fuzz.sh` confirms the legacy header is ignored and malformed IDs never return `200`. - ---- - -### INF-004 — Weak / default signing secret — **Remediated (2026-06-08)** - -| Field | Value | -|-------|--------| -| **Severity** | Critical (pre-fix) → **Low** (retest) | -| **CWE** | CWE-798 (Use of Hard-coded Credentials) | - -**Description:** LOCAL signed URLs are only as strong as `LOCAL_SIGN_SECRET_KEY`; the README example was `your-sign-secret`. - -**Fix:** Secret rotated to a long random value (kept in `.env`, which is gitignored and untracked). Live PoC with the README default secret is rejected (`401`). Operational reminder: rotate again if it is ever exposed; never use the README placeholder in any environment. - ---- - -### INF-005 — Permissive CORS — **Remediated (2026-06-08)** - -| Field | Value | -|-------|--------| -| **Severity** | Medium (pre-fix) → **Low** (retest) | -| **CWE** | CWE-942 (Overly Permissive CORS) | -| **Location** | `src/index.ts` | - -**Description (original):** `app.use(cors())` reflected `Access-Control-Allow-Origin: *`. - -**Fix:** CORS restricted to `https://*.superannotate.com` via an anchored regex origin callback (`/^https:\/\/([a-z0-9-]+\.)+superannotate\.com$/i`). HTTPS is required; suffix (`...superannotate.com.attacker.com`) and prefix (`evilsuperannotate.com`) bypasses are blocked. - -**Live retest:** allowed origins reflected; `https://evil.example`, `https://app.superannotate.com.evil.com`, `https://evilsuperannotate.com`, and `http://app.superannotate.com` all receive **no** `Access-Control-Allow-Origin`. - -**Note:** CORS is browser-enforced only; it is not an authorization control. Server-side/`curl` clients are unaffected (auth remains `AuthSaMiddleware` + `getItem`). - ---- - -### INF-006 — Host header trust for signed URL generation — **Remediated (2026-06-08)** - -| Field | Value | -|-------|--------| -| **Severity** | Medium (pre-fix) → **Low** (retest) | -| **CWE** | CWE-601 (URL Redirection to Untrusted Site) | -| **Location** | `src/routes/storageRouter.ts`, `src/utils/config.ts` | - -**Description (original):** Signed URLs embedded the client-influenced request host (`req.get('host')`), so a hostile `Host`/`X-Forwarded-Host` could mint a validly-signed URL pointing at an attacker domain. - -**Fix:** `GET /storage/` now builds the prefix from `Config.publicBaseUrl()` = `PUBLIC_PROTOCOL` + `PUBLIC_HOST` (env-driven, **required in production**, dev fallback to `localhost:PORT`). The request `Host` header is no longer used for URL generation. - ---- - -### INF-007 — Missing security headers / stored-content rendering — **Remediated (2026-06-08)** - -| Field | Value | -|-------|--------| -| **Severity** | Low–Medium (pre-fix) → **Low** (retest) | -| **CWE** | CWE-693 (Protection Mechanism Failure), CWE-79 (stored XSS via file render) | -| **Location** | `src/index.ts`, `src/routes/storageRouter.ts`, `src/utils/pathSafety.ts` | - -**Description (original):** `X-Powered-By: Express` exposed; no baseline security headers; and the file-download path served assets with an extension-derived MIME and no `nosniff`/`Content-Disposition`, so a stored HTML/SVG asset could render and execute same-origin. - -**Fix:** -- `app.disable('x-powered-by')` + `helmet()` (CSP off — JSON/file API; `Cross-Origin-Resource-Policy: cross-origin` so the SA web app can embed assets; COEP off). Adds `nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: no-referrer`, HSTS. -- `GET /storage/fileSigned` now always sends `X-Content-Type-Options: nosniff` and applies a **default-deny inline policy**: only an `INLINE_SAFE_TYPES` allowlist (raster images, audio/video, PDF) is served inline with its real type; everything else — including `image/svg+xml`, `text/html`, XML, and unknown types — is forced to `application/octet-stream` + `Content-Disposition: attachment`. SDK byte-fetch is unaffected (disposition only changes browser rendering). -- `isSafeSegment` now rejects control characters (`\x00–\x1f`, `\x7f`), closing a CR/LF header-injection vector if a name is ever reflected into a header. - -**Live retest (2026-06-08):** `/health` returns `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: no-referrer`, `Cross-Origin-Resource-Policy: cross-origin`, HSTS; no `X-Powered-By`. Unit tests assert SVG/HTML/XML/unknown → `octet-stream` + `attachment`, PDF → inline + `nosniff`. - -**Residual:** No CSP/sandboxing on inline responses (raster/AV/PDF only, which don't execute author script). If inline SVG/HTML preview is ever required, add a strict CSP or a separate sandbox content origin. - ---- - -### INF-008 — Sensitive operational logging — **Risk accepted (2026-06-08)** - -| Field | Value | -|-------|--------| -| **Severity** | **Low** | -| **CWE** | CWE-532 (Insertion of Sensitive Information into Log File) | -| **Decision** | Owner-accepted as-is on 2026-06-08 | - -**Fixed:** All high-signal/sensitive logs removed — path/item/perms/token/chunk `console.log`s from `pathValidatorMiddleware.ts`, `localRepository.ts`, `annotationRouter.ts`, `storageRouter.ts`, plus the INF-013 debug-log regression in `pathSafety.ts`. - -**Accepted residual (no fix planned):** - -- `src/index.ts` — dev request logger (`${method} ${path}`), gated behind `NODE_ENV !== "production"` (paths only, no tokens/bodies). -- `src/utils/errorHandler.ts` — `console.error("Unhandled error:", err)`. -- `src/routes/checkRouter.ts`, `src/utils/s3Sdk.ts` — infra error logging (no user/token data). - -**Rationale:** the remaining calls log no credentials or user-controlled sensitive values on any hot path; severity is Low. **Conditions of acceptance:** prod must set `NODE_ENV=production`; no middleware may log full headers/requests (the earlier dev token capture must not recur). Revisit if a structured/redacting logger is introduced. - ---- - -### INF-009 — No rate limiting on public fileSigned — **Open** - -| Field | Value | -|-------|--------| -| **Severity** | **Low** | -| **CWE** | CWE-770 | - -**Description:** Unauthenticated endpoint allows unlimited HMAC verification and file existence checks → brute-force / enumeration aid. - ---- - -### INF-010 — Upload size / DoS on POST /annotation — **Open** - -| Field | Value | -|-------|--------| -| **Severity** | **Low** | -| **CWE** | CWE-400 | - -**Description:** Request body streamed to disk/S3 without documented `limit` middleware; large uploads may exhaust disk or incur S3 cost. - ---- - -### INF-011 — Hard-coded dev SuperAnnotate API hosts — **Remediated (2026-06-08)** - -| Field | Value | -|-------|--------| -| **Severity** | High (trust anchor) → **Low** (retest) | -| **Location** | `src/utils/saApi.ts`, `src/utils/config.ts` | - -**Description:** SA hosts (the authN/authZ trust anchor) were hard-coded to the dev environment. - -**Fix:** Hosts resolved from `SA_ITEM_API_HOST` / `SA_USER_API_HOST` via `Config`, **required in production** (throws if unset), with a non-prod default. Resolved once at process start in `saApi.ts` (fail-fast on misconfiguration). - ---- - -### INF-012 — Invalid token returns HTTP 500 — **Remediated (2026-06-08)** - -| Field | Value | -|-------|--------| -| **Severity** | **Low** | -| **CWE** | CWE-755 | - -**Description (original):** Invalid/expired tokens surfaced as `500` because `SuperAnnotateApi.request` discarded the upstream HTTP status, leaving callers to guess from body shape. - -**Fix:** `request` now rejects with a typed `SaApiError(statusCode, body)`. `AuthSaMiddleware` and `PathValidatorMiddleware` map on status: `401/403` → 401, other/transport → 500. **Live retest:** invalid token on `/annotation/` and `/storage/` → **401**. - ---- - -### INF-013 — Debug logging reintroduced in access-map work — **Remediated (2026-06-08)** - -| Field | Value | -|-------|--------| -| **Severity** | **Low–Medium** | -| **CWE** | CWE-532 (Insertion of Sensitive Information into Log File) | -| **Location** | `src/utils/pathSafety.ts`, `src/routes/annotationRouter.ts` | - -**Description:** The access-map change reintroduced `console.log` statements that logged rejected path segments (attacker-influenced item names / manifest entries) and resolved annotation file paths on the hot path. - -**Fix:** Debug `console.log`s removed (verified by owner). - ---- - -## Original PoC status - -```bash -# Security engineer PoC (LOCAL + known secret) -P='../../../../../../etc/passwd' -# ... openssl HMAC ... -curl -i "http://localhost:3005/storage/fileSigned?path=...&expires=...&signature=..." -``` - -| Environment observed | Result | -|---------------------|--------| -| Local dev (2026-06-03) | **401** `INVALID_SIGNATURE` (custom secret) — no file leak | -| Local dev (2026-06-08) | **401** for traversal + forged/expired/garbage signatures — no file leak | -| With files-jail + matching secret | Path rejected by `validateFilesSignature` → **401**; never escapes `files/` | - ---- - -## Severity summary - -| ID | Title | Status (2026-06-08) | Severity | -|----|-------|--------|----------| -| INF-001 | Path traversal (LOCAL fileSigned) | **Remediated** | Low (retest) | -| INF-002 | S3 fileSigned signature bypass | **Remediated** | Low (retest) | -| INF-003 | IDOR sa-file-path | **Remediated** | Low (retest) | -| INF-004 | Signing secret strength | **Remediated** | Low (retest) | -| INF-005 | Permissive CORS | **Remediated** | Low (retest) | -| INF-006 | Host header in signed URLs | **Remediated** | Low (retest) | -| INF-007 | Missing security headers / stored-content rendering | **Remediated** | Low (retest) | -| INF-008 | Sensitive logging (residual) | **Risk accepted** | Low | -| INF-009 | No rate limit fileSigned | Open | Low | -| INF-010 | Upload DoS (annotation POST) | Open | Low | -| INF-011 | Hard-coded SA dev hosts | **Remediated** | Low (retest) | -| INF-012 | Invalid token → 500 (auth middleware) | **Remediated** | Low (retest) | -| INF-013 | Debug logging reintroduced (access-map work) | **Remediated** | Low | - -**Open items:** INF-009, INF-010 — both Low. INF-008 risk-accepted. - ---- - -## Suggested verification cadence - -1. Run `./infosec/scripts/run-all.sh` on every release candidate (latest run: 0 failures, 2026-06-08). -2. Add a CI job that runs `07-static-path-safety.sh` (no server required). -3. Extend `05-header-and-input-fuzz.sh` with a **valid** `SA_ACCESS_TOKEN` (from a CI secret) for true authorized-access regression tests against the access-map flow. -4. Verify production sets `NODE_ENV=production`, `SA_ITEM_API_HOST`/`SA_USER_API_HOST`, and `PUBLIC_HOST`/`PUBLIC_PROTOCOL` (all required/fail-fast in prod). - ---- - -## References - -- Application README — `LOCAL_SIGN_SECRET_KEY`, endpoint table -- Prior internal review — path traversal PoC -- Test pack — `infosec/tests/` diff --git a/infosec/scripts/run-all.sh b/infosec/scripts/run-all.sh deleted file mode 100755 index ba4faa0..0000000 --- a/infosec/scripts/run-all.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# Run all infosec probes and aggregate output. -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -INFOSEC="$(cd "$(dirname "$0")/.." && pwd)" -RESULTS="${INFOSEC}/results" -TS="$(date -u +%Y%m%dT%H%M%SZ)" -OUT="${RESULTS}/run-${TS}" -BASE_URL="${BASE_URL:-http://localhost:3005}" - -mkdir -p "${OUT}" -export BASE_URL -export LOCAL_SIGN_SECRET_KEY="${LOCAL_SIGN_SECRET_KEY:-your-sign-secret}" - -echo "Infosec run ${TS}" | tee "${OUT}/summary.txt" -echo "BASE_URL=${BASE_URL}" | tee -a "${OUT}/summary.txt" -echo "" | tee -a "${OUT}/summary.txt" - -run_test() { - local name="$1" - local script="$2" - echo "=== ${name} ===" | tee -a "${OUT}/summary.txt" - if bash "${script}" > "${OUT}/${name}.log" 2>&1; then - echo "PASS ${name}" | tee -a "${OUT}/summary.txt" - return 0 - else - echo "FAIL ${name} (see ${name}.log)" | tee -a "${OUT}/summary.txt" - return 1 - fi -} - -FAILURES=0 -run_test "01-unauthenticated-access" "${INFOSEC}/tests/01-unauthenticated-access.sh" || FAILURES=$((FAILURES + 1)) -run_test "02-path-traversal-fileSigned" "${INFOSEC}/tests/02-path-traversal-fileSigned.sh" || FAILURES=$((FAILURES + 1)) -run_test "03-signature-abuse" "${INFOSEC}/tests/03-signature-abuse.sh" || FAILURES=$((FAILURES + 1)) -run_test "04-auth-bypass-probes" "${INFOSEC}/tests/04-auth-bypass-probes.sh" || FAILURES=$((FAILURES + 1)) -run_test "05-header-and-input-fuzz" "${INFOSEC}/tests/05-header-and-input-fuzz.sh" || FAILURES=$((FAILURES + 1)) -run_test "06-cors-and-metadata" "${INFOSEC}/tests/06-cors-and-metadata.sh" || FAILURES=$((FAILURES + 1)) -run_test "07-static-path-safety" "${INFOSEC}/tests/07-static-path-safety.sh" || FAILURES=$((FAILURES + 1)) - -echo "" | tee -a "${OUT}/summary.txt" -echo "Finished. Failures: ${FAILURES}" | tee -a "${OUT}/summary.txt" -echo "Latest symlink: ${RESULTS}/latest -> ${OUT}" -ln -sfn "${OUT}" "${RESULTS}/latest" - -exit "${FAILURES}" diff --git a/infosec/tests/01-unauthenticated-access.sh b/infosec/tests/01-unauthenticated-access.sh deleted file mode 100755 index 16be782..0000000 --- a/infosec/tests/01-unauthenticated-access.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Probe endpoints that should not require SA auth. -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# shellcheck source=_lib.sh -source "${SCRIPT_DIR}/_lib.sh" - -echo "Target: ${BASE_URL}" - -code=$(curl_body "${BASE_URL}/health") -assert_status "200" "${code}" "GET /health" - -code=$(curl_body "${BASE_URL}/storage/fileSigned") -assert_status "400" "${code}" "GET /storage/fileSigned (missing params)" -body_contains "MISSING_QUERY_PARAMETERS" - -code=$(curl_body "${BASE_URL}/annotation/") -assert_status "401" "${code}" "GET /annotation/ without token" - -# /storage/ (manifest -> signed URLs) is protected by AuthSaMiddleware. -code=$(curl_body "${BASE_URL}/storage/") -assert_status "401" "${code}" "GET /storage/ without token" - -# Legacy endpoints were removed in the item-manifest redesign (no longer exist). -code=$(curl_body "${BASE_URL}/storage/file") -assert_status "404" "${code}" "GET /storage/file removed" - -code=$(curl_body "${BASE_URL}/storage/signedUrl") -assert_status "404" "${code}" "GET /storage/signedUrl removed" - -code=$(curl_body "${BASE_URL}/check") -assert_status "401" "${code}" "GET /check without token" - -echo "All unauthenticated-access checks completed." diff --git a/infosec/tests/02-path-traversal-fileSigned.sh b/infosec/tests/02-path-traversal-fileSigned.sh deleted file mode 100755 index 858940f..0000000 --- a/infosec/tests/02-path-traversal-fileSigned.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# Attempt local path traversal via /storage/fileSigned (original sec eng PoC + variants). -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source "${SCRIPT_DIR}/_lib.sh" - -EXPIRES=$(( ($(date +%s) + 86400) * 1000 )) - -run_traversal_case() { - local label="$1" - local path_arg="$2" - local sig_path="$3" - - local sig - sig=$(sign_local_path "${sig_path}" "${EXPIRES}") - local enc - enc=$(urlencode "${path_arg}") - - local code - code=$(curl_body "${BASE_URL}/storage/fileSigned?path=${enc}&expires=${EXPIRES}&signature=${sig}") - echo "Case: ${label}" - cat /tmp/infosec_body.json - echo "" - - # Must NOT return 200 with system file content - if [[ "${code}" == "200" ]]; then - if head -c 64 /tmp/infosec_body.json | grep -qE 'root:|daemon:|/bin/'; then - echo "CRITICAL: traversal may have returned passwd-like content" - return 1 - fi - echo "WARN: HTTP 200 for traversal case — verify body manually" - return 1 - fi - - # After fix: expect 400 (path) or 401 (sig mismatch / path rejected in validateSignature) - assert_status_in "${label}" "${code}" "400" "401" "404" -} - -echo "Target: ${BASE_URL}" -echo "Using LOCAL_SIGN_SECRET_KEY length: ${#LOCAL_SIGN_SECRET_KEY}" - -run_traversal_case "classic dot-dot" "../../../../../../etc/passwd" "../../../../../../etc/passwd" -run_traversal_case "encoded dots" "..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd" "..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd" -run_traversal_case "absolute path" "/etc/passwd" "/etc/passwd" -run_traversal_case "null byte suffix" $'1/2/file.pdf%00.txt' $'1/2/file.pdf%00.txt' - -echo "Path traversal probes did not achieve obvious file read." diff --git a/infosec/tests/03-signature-abuse.sh b/infosec/tests/03-signature-abuse.sh deleted file mode 100755 index caca37a..0000000 --- a/infosec/tests/03-signature-abuse.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# HMAC / signed URL abuse probes for /storage/fileSigned. -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source "${SCRIPT_DIR}/_lib.sh" - -SAFE_PATH="1/2/3/4/test-nonexistent.pdf" -FUTURE=$(( ($(date +%s) + 86400) * 1000 )) -PAST=$(( ($(date +%s) - 3600) * 1000 )) - -echo "Target: ${BASE_URL}" - -# Wrong secret / garbage signature -code=$(curl_body "${BASE_URL}/storage/fileSigned?path=$(urlencode "${SAFE_PATH}")&expires=${FUTURE}&signature=deadbeef") -assert_status_in "invalid hex signature" "${code}" "401" "400" - -# Expired token -good_sig=$(sign_local_path "${SAFE_PATH}" "${PAST}") -code=$(curl_body "${BASE_URL}/storage/fileSigned?path=$(urlencode "${SAFE_PATH}")&expires=${PAST}&signature=${good_sig}") -assert_status "401" "${code}" "expired signature" -body_contains "INVALID_SIGNATURE" - -# Tamper path after signing (signature for different path) -sig=$(sign_local_path "${SAFE_PATH}" "${FUTURE}") -tampered="1/2/3/4/other.pdf" -code=$(curl_body "${BASE_URL}/storage/fileSigned?path=$(urlencode "${tampered}")&expires=${FUTURE}&signature=${sig}") -assert_status "401" "${code}" "path tamper" - -# Empty signature -code=$(curl_body "${BASE_URL}/storage/fileSigned?path=$(urlencode "${SAFE_PATH}")&expires=${FUTURE}&signature=") -assert_status_in "empty signature" "${code}" "400" "401" - -echo "Signature abuse probes completed." diff --git a/infosec/tests/04-auth-bypass-probes.sh b/infosec/tests/04-auth-bypass-probes.sh deleted file mode 100755 index 9fbb557..0000000 --- a/infosec/tests/04-auth-bypass-probes.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# Authentication edge cases (no valid SA token required). -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source "${SCRIPT_DIR}/_lib.sh" - -echo "Target: ${BASE_URL}" - -# Invalid token should not reach handler with 200 -code=$(curl_body "${BASE_URL}/annotation/" \ - -H "x-sa-access-token: not-a-valid-jwt" \ - -H "sa-team-id: 1" \ - -H "sa-project-id: 2" \ - -H "sa-folder-id: 1" \ - -H "sa-item-id: 1") -assert_status_in "invalid token" "${code}" "401" "500" -if [[ "${code}" == "500" ]]; then - echo "NOTE: invalid token yields 500 (SA API error) — prefer 401 to avoid ambiguity" -fi - -# Empty Bearer -code=$(curl_body "${BASE_URL}/annotation/" \ - -H "x-sa-access-token: " \ - -H "sa-team-id: 1" \ - -H "sa-project-id: 2" \ - -H "sa-folder-id: 1" \ - -H "sa-item-id: 1") -assert_status_in "empty token" "${code}" "401" "400" "500" - -# Duplicate headers (array) — Express uses first value typically -code=$(curl_body "${BASE_URL}/annotation/" \ - -H "x-sa-access-token: invalid" \ - -H "x-sa-access-token: also-invalid" \ - -H "sa-team-id: 1" \ - -H "sa-project-id: 2" \ - -H "sa-folder-id: 1" \ - -H "sa-item-id: 1") -assert_status_in "duplicate auth headers" "${code}" "401" "500" - -echo "Auth bypass probes completed (no bypass demonstrated without valid token)." diff --git a/infosec/tests/05-header-and-input-fuzz.sh b/infosec/tests/05-header-and-input-fuzz.sh deleted file mode 100755 index f543b5f..0000000 --- a/infosec/tests/05-header-and-input-fuzz.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# Path/header fuzzing on protected routes. -# In the item-manifest design the storage path is derived from SuperAnnotate -# (getItem), never from client headers, so there is no sa-file-path to abuse. -# This probe confirms malformed IDs never yield a 200 file leak and that the -# legacy client-path header is gone. -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source "${SCRIPT_DIR}/_lib.sh" - -TOKEN="${SA_ACCESS_TOKEN:-invalid-token-for-fuzz}" - -probe() { - local label="$1" - local endpoint="$2" - shift 2 - local code - code=$(curl_body "${BASE_URL}${endpoint}" \ - -H "x-sa-access-token: ${TOKEN}" \ - "$@") - echo "${label}: HTTP ${code}" - head -c 200 /tmp/infosec_body.json - echo "" - # Any 200 here would mean an unauthenticated/invalid-token caller got data. - if [[ "${code}" == "200" ]]; then - echo "FAIL: ${label} returned 200" - exit 1 - fi -} - -echo "Target: ${BASE_URL} (token is placeholder unless SA_ACCESS_TOKEN set)" - -# Legacy client-controlled path header must no longer be honored anywhere. -probe "legacy sa-file-path on /storage/" "/storage/" \ - -H "sa-team-id: 1" \ - -H "sa-project-id: 2" \ - -H "sa-file-path: ../../../../../../etc/passwd" - -# Malformed numeric identifiers on the new item-based surface. -probe "negative team id (/storage/)" "/storage/" \ - -H "sa-team-id: -1" \ - -H "sa-project-id: 2" \ - -H "sa-folder-id: 3" \ - -H "sa-item-id: 4" - -probe "zero project id (/storage/)" "/storage/" \ - -H "sa-team-id: 1" \ - -H "sa-project-id: 0" \ - -H "sa-folder-id: 3" \ - -H "sa-item-id: 4" - -probe "non-numeric team id (/annotation/)" "/annotation/" \ - -H "sa-team-id: abc" \ - -H "sa-project-id: 2" \ - -H "sa-folder-id: 3" \ - -H "sa-item-id: 4" - -probe "missing folder/item (/storage/)" "/storage/" \ - -H "sa-team-id: 1" \ - -H "sa-project-id: 2" - -echo "Header fuzz completed." diff --git a/infosec/tests/06-cors-and-metadata.sh b/infosec/tests/06-cors-and-metadata.sh deleted file mode 100755 index e186b19..0000000 --- a/infosec/tests/06-cors-and-metadata.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# CORS, security headers, and fingerprinting. -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source "${SCRIPT_DIR}/_lib.sh" - -HDR_FILE="/tmp/infosec_headers.txt" - -curl -sS -D "${HDR_FILE}" -o /dev/null -X OPTIONS "${BASE_URL}/annotation/" \ - -H "Origin: https://evil.example" \ - -H "Access-Control-Request-Method: GET" - -if grep -qi 'Access-Control-Allow-Origin: \*' "${HDR_FILE}"; then - echo "FINDING: CORS allows any origin (*)" -else - echo "OK: CORS is not wildcard" -fi - -curl -sS -D "${HDR_FILE}" -o /dev/null "${BASE_URL}/health" - -for h in "X-Content-Type-Options" "X-Frame-Options" "Content-Security-Policy" "Strict-Transport-Security"; do - if grep -qi "^${h}:" "${HDR_FILE}"; then - echo "OK: ${h} present" - else - echo "FINDING: missing ${h} on /health" - fi -done - -if grep -qi 'X-Powered-By: Express' "${HDR_FILE}"; then - echo "FINDING: X-Powered-By exposes Express" -fi - -echo "CORS/metadata probes completed." diff --git a/infosec/tests/07-static-path-safety.sh b/infosec/tests/07-static-path-safety.sh deleted file mode 100755 index 284fa5b..0000000 --- a/infosec/tests/07-static-path-safety.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -# Static path-jail verification against application pathSafety module (read-only import). -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -cd "${ROOT}" - -npx ts-node --transpile-only "${ROOT}/infosec/tests/static-path-safety-runner.ts" -echo "Static path safety module verification completed." diff --git a/infosec/tests/_lib.sh b/infosec/tests/_lib.sh deleted file mode 100755 index fb0060e..0000000 --- a/infosec/tests/_lib.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# Shared helpers for infosec test scripts. -set -euo pipefail - -BASE_URL="${BASE_URL:-http://localhost:3005}" -LOCAL_SIGN_SECRET_KEY="${LOCAL_SIGN_SECRET_KEY:-your-sign-secret}" - -assert_status() { - local expected="$1" - local actual="$2" - local label="$3" - if [[ "${actual}" != "${expected}" ]]; then - echo "ASSERT FAIL: ${label} expected HTTP ${expected}, got ${actual}" - return 1 - fi - echo "OK: ${label} -> HTTP ${actual}" -} - -assert_status_in() { - local label="$1" - local actual="$2" - shift 2 - for expected in "$@"; do - if [[ "${actual}" == "${expected}" ]]; then - echo "OK: ${label} -> HTTP ${actual} (accepted)" - return 0 - fi - done - echo "ASSERT FAIL: ${label} got HTTP ${actual}, expected one of: $*" - return 1 -} - -curl_body() { - curl -sS -o /tmp/infosec_body.json -w "%{http_code}" "$@" -} - -sign_local_path() { - local p="$1" - local expires="$2" - printf '%s' "${p}-${expires}" | openssl dgst -sha256 -hmac "${LOCAL_SIGN_SECRET_KEY}" | sed 's/^.*= //' -} - -urlencode() { - python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1" -} - -body_contains() { - local needle="$1" - grep -q "${needle}" /tmp/infosec_body.json -} diff --git a/infosec/tests/static-path-safety-runner.ts b/infosec/tests/static-path-safety-runner.ts deleted file mode 100644 index 5e9fb06..0000000 --- a/infosec/tests/static-path-safety-runner.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { resolveItemsFilePath, resolveFilesPath, isUnsafeRelativePath, isSafeSegment } from "../../src/utils/pathSafety"; - -const base = "/var/data/storage"; - -let failed = 0; -function check(label: string, actual: boolean, expected: boolean, detail?: unknown): void { - if (actual !== expected) { - console.error("FAIL", label, { actual, expected, detail }); - failed++; - } else { - console.log("OK", label); - } -} - -// items root jail (annotation + manifest paths) -const itemsCases: Array<{ path: string; shouldResolve: boolean; shouldMarkUnsafe: boolean }> = [ - { path: "1/2/3/file.pdf", shouldResolve: true, shouldMarkUnsafe: false }, - { path: "../../../../../../etc/passwd", shouldResolve: false, shouldMarkUnsafe: true }, - { path: "/etc/passwd", shouldResolve: false, shouldMarkUnsafe: true }, - { path: "1/2/../../../etc/passwd", shouldResolve: false, shouldMarkUnsafe: true }, - { path: "1/2/foo..bar/file", shouldResolve: true, shouldMarkUnsafe: false }, -]; -for (const c of itemsCases) { - check(`items resolve ${c.path}`, resolveItemsFilePath(c.path, base) !== null, c.shouldResolve); - check(`items unsafe ${c.path}`, isUnsafeRelativePath(c.path), c.shouldMarkUnsafe); -} - -// files root jail (signed raw assets) -const filesCases: Array<{ path: string; shouldResolve: boolean }> = [ - { path: "contract.pdf", shouldResolve: true }, - { path: "../../etc/passwd", shouldResolve: false }, - { path: "/etc/passwd", shouldResolve: false }, - { path: "a\0b", shouldResolve: false }, -]; -for (const c of filesCases) { - check(`files resolve ${JSON.stringify(c.path)}`, resolveFilesPath(c.path, base) !== null, c.shouldResolve); -} - -// single-segment validation (SA item names + manifest entries) -const segmentCases: Array<{ seg: unknown; safe: boolean }> = [ - { seg: "invoice_42", safe: true }, - { seg: "frame_001.png", safe: true }, - { seg: "a/b", safe: false }, - { seg: "a\\b", safe: false }, - { seg: "..", safe: false }, - { seg: ".", safe: false }, - { seg: "a\0b", safe: false }, - { seg: "", safe: false }, - { seg: undefined, safe: false }, - { seg: "x".repeat(256), safe: false }, -]; -for (const c of segmentCases) { - check(`segment ${JSON.stringify(c.seg)}`, isSafeSegment(c.seg), c.safe); -} - -if (failed > 0) { - process.exit(1); -} -console.log("Static path safety checks passed.");