Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .snyk
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,12 @@
version: v1.25.0
ignore: {}
patch: {}
# Snyk Code skips test-only code: unit and browser tests (which start plain
# HTTP servers on 127.0.0.1 as fixtures) and the local server the tests and
# the deploy smoke check use. None of it ships to users.
exclude:
code:
- "tests/**"
- "__tests__/**"
- "test/**"
- scripts/serve-isolated.mjs
20 changes: 10 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions docs/SECURITY-PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,36 @@ versions: none has a high or critical advisory.
After the fixes: 0 blocking, 5 excepted (the two packages above), 14
moderate or low.

## Snyk

Snyk is connected through its GitHub integration (a webhook on the repo)
to the `akankshatrehun` Snyk organisation, where `reframe-oss/reframe` has
two projects: **package.json** (open-source dependencies) and **Code
analysis** (Snyk Code). It adds two checks to pull requests from this
repository, `security/snyk` and `license/snyk`, and `snyk.yml` runs the CLI
(`snyk test`, high and critical only) on pushes to main, on same-repo pull
requests and weekly. Snyk can't read `bun.lock`, so that job tests a
`package-lock.json` generated from `package.json`; the bun.lock policy above
is the exact check.

`.snyk` holds the policy: open-source ignores (none) and the paths Snyk Code
skips (test-only code).

### Snyk Code triage, 2026-11-06

14 findings on `d1ff303`:

| Finding | Where | Outcome |
|---|---|---|
| Path traversal (high) | `ffmpeg.worker.ts`: the engine's input file name took its extension from the user's file name | **Fixed**: every engine file name uses a letters-and-digits extension (`safeExtension`); a test feeds `../` names |
| ReDoS (high) | `sw.js`: `cache.match(request.url)` | **False positive**: Cache Storage matching compares URLs, it builds no regular expression. Ignored in Snyk with this reason |
| Hardcoded password (medium) | `check-deploy.mjs`: an object key named `pass` | **Fixed**: keys are the `RESULT` constants |
| postMessage origin (low) | `sw.js` message handler | **Fixed**: messages from other origins are ignored |
| postMessage origin (low) | `ffmpeg.worker.ts` message handler | **False positive**: a dedicated worker only receives messages from the page that created it. Ignored in Snyk |
| Path traversal ×6 (low) | `build-sw-manifest.mjs`, `strip-sourcemaps.mjs`: a directory from the command line | **Fixed**: both refuse a directory outside the repository (one deletes files) |
| Cleartext HTTP, no rate limit (medium ×2) | `scripts/serve-isolated.mjs`: the local test server on 127.0.0.1 | **Excluded** from Snyk Code (test-only) |
| Cleartext HTTP (low) | `deployCheck.test.ts`: a fixture server | **Excluded** (test-only) |

## Secrets

On 2026-11-06, gitleaks 8.21.2 (redacted) found no secrets in the full
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"lottie-web": "^5.12.2",
"lucide-react": "^0.577.0",
"next": "16.4.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"reframe": ".",
"tailwind-merge": "^3.6.0",
"wasm-feature-detect": "^1.8.0"
Expand All @@ -42,8 +42,8 @@
"@testing-library/user-event": "^14.4.3",
"@types/bun": "^1.3.14",
"@types/node": "^25",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"chromatic": "^18.5.0",
"eslint": "9.39.5",
"eslint-config-next": "16.4.1",
Expand All @@ -57,8 +57,8 @@
"vitest": "^4.1.12"
},
"overrides": {
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"undici": "^7.30.1",
"vite": "^8.2.4",
"zod": "^4.6.6",
Expand Down
4 changes: 3 additions & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,10 @@ self.addEventListener("activate", (event) => {
);
});

// The page asks for this when the user chooses to load an update.
// The page asks for this when the user chooses to load an update. Only this
// site's own pages can control the worker.
self.addEventListener("message", (event) => {
if (event.origin !== self.location.origin) return;
if (event.data && event.data.type === "skip-waiting") self.skipWaiting();
});

Expand Down
9 changes: 7 additions & 2 deletions scripts/build-sw-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// Source maps, the headers file and sw.js itself are left out.
import { createHash } from "node:crypto";
import { readdir, readFile, writeFile } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";

const SKIP = [/\.map$/, /^_headers$/, /^sw\.js$/, /(^|\/)\.DS_Store$/];
Expand Down Expand Up @@ -68,7 +68,12 @@ export function injectManifest(source, manifest) {
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const root = process.argv[2] ?? "out";
// It rewrites out/sw.js, so it only works inside the repository it runs in.
const root = resolve(process.argv[2] ?? "out");
if (!root.startsWith(process.cwd() + sep)) {
console.error(`build-sw-manifest: ${root} is outside ${process.cwd()}; refusing.`);
process.exit(2);
}
const manifest = await buildManifest(root);
const swPath = join(root, "sw.js");
await writeFile(swPath, injectManifest(await readFile(swPath, "utf8"), manifest));
Expand Down
8 changes: 4 additions & 4 deletions scripts/deploy/check-deploy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,10 @@ if (page.outcome.status && page.outcome.status >= 500) results.push(RESULT.UNAVA
const overall = overallResult(results);

const summary = {
pass: "All checks passed.",
fail: "Some checks failed.",
protected: "Not checked: the deployment requires authentication (set VERCEL_AUTOMATION_BYPASS_SECRET for Vercel previews).",
unavailable: "Not checked: the deployment could not be reached.",
[RESULT.PASS]: "All checks passed.",
[RESULT.FAIL]: "Some checks failed.",
[RESULT.PROTECTED]: "Not checked: the deployment requires authentication (set VERCEL_AUTOMATION_BYPASS_SECRET for Vercel previews).",
[RESULT.UNAVAILABLE]: "Not checked: the deployment could not be reached.",
}[overall];

if (json) console.log(JSON.stringify({ url: base.href, result: overall, commit, checks }, null, 2));
Expand Down
9 changes: 7 additions & 2 deletions scripts/strip-sourcemaps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@
//
// node scripts/strip-sourcemaps.mjs [dir] (default: out)
import { readdir, unlink } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve, sep } from "node:path";

const root = process.argv[2] ?? "out";
// It deletes files, so it only works inside the repository it is run from.
const root = resolve(process.argv[2] ?? "out");
if (!root.startsWith(process.cwd() + sep)) {
console.error(`strip-sourcemaps: ${root} is outside ${process.cwd()}; refusing.`);
process.exit(2);
}

async function* walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
Expand Down
17 changes: 13 additions & 4 deletions src/lib/ffmpeg.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,15 @@ function loadTextFont(): Promise<Uint8Array> {
return textFontBytes;
}

/**
* The extension of a user's file name, reduced to letters and digits, for the
* neutral names files get in FFmpeg's filesystem: the user's file name
* (which could hold "/" or "..") is never used as a path.
*/
function safeExtension(name: string, fallback: string): string {
return (name.split(".").pop() ?? "").replace(/[^a-z0-9]/gi, "").slice(0, 8) || fallback;
}

async function readFileBytes(file: SerializedFile): Promise<Uint8Array> {
if (file.data) return new Uint8Array(file.data);
if (file.blob) return new Uint8Array(await file.blob.arrayBuffer());
Expand Down Expand Up @@ -249,7 +258,7 @@ async function runExport(request: ExportRequest): Promise<ResultPayload> {
const recipe = request.recipe;
const { width: targetW, height: targetH } = resolveOutputSize(recipe);

const ext = request.file.name.split(".").pop() ?? "mp4";
const ext = safeExtension(request.file.name, "mp4");
const inputName = `input_${sessionId}.${ext}`;

const { filename: outputName, mimeType } = getOutputConfig(recipe.format, sessionId);
Expand All @@ -276,7 +285,7 @@ async function runExport(request: ExportRequest): Promise<ResultPayload> {
}

const hasOverlay = !!request.overlayFile;
const overlayExt = request.overlayFile?.name.split(".").pop() ?? "png";
const overlayExt = safeExtension(request.overlayFile?.name ?? "", "png");
const overlayInputName = `overlay_${sessionId}.${overlayExt}`;
if (hasOverlay) {
cleanupFiles.add(overlayInputName);
Expand Down Expand Up @@ -478,7 +487,7 @@ async function runJoin(request: JoinRequest): Promise<ResultPayload> {
// New Files over the same bytes (no copy) with neutral names: the user's
// file names never enter FFmpeg's filesystem.
const sources = request.clips.map(({ file }, i) => {
const ext = (file.name.split(".").pop() ?? "mp4").replace(/[^a-z0-9]/gi, "").slice(0, 8) || "mp4";
const ext = safeExtension(file.name, "mp4");
return new File([file], `clip${i}.${ext}`, { type: file.type });
});
const outputName = `joined_${sessionId}.mp4`;
Expand Down Expand Up @@ -590,7 +599,7 @@ async function runFrames(request: FramesRequest, signal: AbortSignal): Promise<v
if (!ffmpeg) throw new Error("FFmpeg engine is not loaded.");
// A new File over the same bytes (no copy) with a neutral name: the user's
// file name never enters FFmpeg's filesystem.
const ext = (request.file.name.split(".").pop() ?? "mp4").replace(/[^a-z0-9]/gi, "").slice(0, 8) || "mp4";
const ext = safeExtension(request.file.name, "mp4");
const source = new File([request.file], `source.${ext}`, { type: request.file.type });
// One thread on the multi-threaded core: never FFmpeg's automatic count,
// which deadlocks core-mt (see MT_THREAD_CAP).
Expand Down
17 changes: 17 additions & 0 deletions src/lib/tests/ffmpegWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,4 +463,21 @@ describe("ffmpeg.worker", () => {
);
expect(ffmpeg().execCalls).toHaveLength(1);
});

it("never uses the user's file name as a path in FFmpeg's filesystem", async () => {
await startWorker({ isolated: false });
send({ type: "load" });
await waitFor("ready");
send({
...exportRequest(),
file: { name: "../../etc/holiday.mp4/..", type: "video/mp4", data: new ArrayBuffer(16) },
overlayFile: { name: "logo.p/ng", type: "image/png", data: new ArrayBuffer(4) },
overlayOptions: { file: null, position: "bottom-right", size: 100, opacity: 100 },
});
await waitFor("result");
const written = [...ffmpeg().writes.keys()];
expect(written).toContain("input_job1.mp4");
expect(written).toContain("overlay_job1.png");
expect(written.some((name) => name.includes("/") || name.includes(".."))).toBe(false);
});
});
25 changes: 23 additions & 2 deletions src/lib/tests/serviceWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,26 @@ type Route = (url: URL, request: { method: string; mode: string }) => string;
/** Runs sw.js against a stand-in `self`, as the browser would, and returns its test hooks. */
function loadWorker(precache: unknown) {
const listeners: string[] = [];
const handlers: Record<string, (event: unknown) => void> = {};
let skipped = 0;
const self = {
location: { origin: "https://reframe.test" },
addEventListener: (type: string) => listeners.push(type),
addEventListener: (type: string, handler: (event: unknown) => void) => {
listeners.push(type);
handlers[type] = handler;
},
skipWaiting: () => {
skipped++;
},
} as Record<string, unknown>;
const source = precache ? injectManifest(SOURCE, precache) : SOURCE;
new Function("self", "caches", source)(self, {});
return { ...(self.__reframeSw as { route: Route; pageKey: (path: string) => string }), listeners };
return {
...(self.__reframeSw as { route: Route; pageKey: (path: string) => string }),
listeners,
handlers,
skipped: () => skipped,
};
}

const PRECACHE = { version: "abc", pages: ["/", "/contact", "/privacy"], assets: ["/_next/static/chunks/app.js"] };
Expand Down Expand Up @@ -58,6 +71,14 @@ describe("service worker routing (public/sw.js)", () => {
expect(sw.listeners).toEqual(expect.arrayContaining(["install", "activate", "fetch", "message"]));
});

it("loads an update only when this site's own page asks", () => {
const worker = loadWorker(PRECACHE);
worker.handlers.message!({ origin: "https://evil.example", data: { type: "skip-waiting" } });
expect(worker.skipped()).toBe(0);
worker.handlers.message!({ origin: "https://reframe.test", data: { type: "skip-waiting" } });
expect(worker.skipped()).toBe(1);
});

it("keeps every engine file the page loads, in either mode", () => {
for (const url of [...engineFileUrls(true), ...engineFileUrls(false)]) {
expect(sw.route(new URL(url), get())).toBe("engine");
Expand Down
Loading