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
2 changes: 1 addition & 1 deletion apps/media-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ RUN bun test src/__tests__/lib/audio-quality-policy.test.ts src/__tests__/lib/au
&& bun test src/__tests__/lib/live-transcribe.test.ts src/__tests__/routes/live-transcribe.test.ts \
&& bun test src/__tests__/lib/media-routes-real-world.integration.test.ts \
&& bun test src/__tests__/lib/media-video.integration.test.ts \
&& bun test src/__tests__/lib/isolated-worker-pool.test.ts src/__tests__/lib/media-probe-hardening.test.ts src/__tests__/lib/process-hardening.test.ts \
&& bun test src/__tests__/lib/isolated-worker-pool.test.ts src/__tests__/lib/media-probe-hardening.test.ts src/__tests__/lib/process-hardening.test.ts src/__tests__/lib/legacy-codecs.integration.test.ts \
&& bun test src/__tests__/routes/thumbnail-remote.test.ts src/__tests__/routes/health.test.ts

RUN MEDIA_SERVER_RECORDING_PERFORMANCE_TESTS=1 bun test \
Expand Down
1 change: 1 addition & 0 deletions apps/media-server/Dockerfile.standalone
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ COPY src ./src
COPY capcodec.md ./capcodec.md
# Files whose tables claim far more than they hold must probe at once, or the image is not built.
RUN bun test src/__tests__/lib/media-probe-sample-counts.test.ts
RUN bun test src/__tests__/lib/legacy-codecs.integration.test.ts

ENV PORT=3456
# Self-hosted storage usually sits on a private network next to this container.
Expand Down
8 changes: 8 additions & 0 deletions apps/media-server/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ groups="$gid"
for device in /dev/nvidia* /dev/dri/*; do
[ -c "$device" ] || continue
device_gid="$(stat -c %g "$device")"
# A device anyone may read and write needs no group, and the root group
# is never handed out for one. NVIDIA nodes usually own their devices
# as root:root with mode 666.
if [ "$device_gid" = 0 ]; then
case "$(stat -c %a "$device")" in
*[67]) continue ;;
esac
fi
case ",$groups," in
*",$device_gid,"*) ;;
*) groups="$groups,$device_gid" ;;
Expand Down
219 changes: 219 additions & 0 deletions apps/media-server/src/__tests__/lib/legacy-codecs.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { probeVideoFile } from "../../lib/media-probe";
import { stopProbeWorkers } from "../../lib/media-probe-pool";
import { spawn } from "../../lib/media-spawn";

// Older codecs a regular Cap import can carry, in the containers the probe
// reads (Matroska, MP4 and 3GP). Each fixture is made with the local FFmpeg,
// then probed and transcoded to H.264 and AAC through the same hardened spawn
// the server uses, so the codec and decoder lists are what decides. A family
// whose encoder this FFmpeg lacks is skipped here.
type Fixture = {
name: string;
file: string;
video: string[];
audio: string[] | null;
encoders: string[];
};

const FIXTURES: Fixture[] = [
{
name: "MPEG-1 video with MPEG audio layer 2",
file: "mpeg1.mkv",
video: ["-c:v", "mpeg1video"],
audio: ["-c:a", "mp2"],
encoders: ["mpeg1video", "mp2"],
},
{
name: "MPEG-2 video with MPEG audio layer 2",
file: "mpeg2.mkv",
video: ["-c:v", "mpeg2video"],
audio: ["-c:a", "mp2"],
encoders: ["mpeg2video", "mp2"],
},
{
name: "Windows Media video 7 with Windows Media audio 1",
file: "wmv1.mkv",
video: ["-c:v", "wmv1"],
audio: ["-c:a", "wmav1"],
encoders: ["wmv1", "wmav1"],
},
{
name: "Windows Media video 8 with Windows Media audio 2",
file: "wmv2.mkv",
video: ["-c:v", "wmv2"],
audio: ["-c:a", "wmav2"],
encoders: ["wmv2", "wmav2"],
},
{
name: "early MPEG-4 (DivX 3) with Microsoft ADPCM",
file: "msmpeg4v3.mkv",
video: ["-c:v", "msmpeg4"],
audio: ["-c:a", "adpcm_ms"],
encoders: ["msmpeg4", "adpcm_ms"],
},
{
name: "early MPEG-4 version 2 with IMA ADPCM",
file: "msmpeg4v2.mkv",
video: ["-c:v", "msmpeg4v2"],
audio: ["-c:a", "adpcm_ima_wav"],
encoders: ["msmpeg4v2", "adpcm_ima_wav"],
},
{
name: "Flash video",
file: "flv1.mkv",
video: ["-c:v", "flv"],
audio: ["-c:a", "aac"],
encoders: ["flv", "aac"],
},
{
name: "H.263 from a phone",
file: "phone.3gp",
video: ["-c:v", "h263"],
audio: ["-c:a", "aac", "-ar", "8000", "-ac", "1"],
encoders: ["h263", "aac"],
},
{
name: "H.263 with AMR narrowband audio",
file: "amrnb.3gp",
video: ["-c:v", "h263"],
audio: ["-c:a", "libopencore_amrnb", "-ar", "8000", "-ac", "1"],
encoders: ["h263", "libopencore_amrnb"],
},
{
name: "H.263 with AMR wideband audio",
file: "amrwb.3gp",
video: ["-c:v", "h263"],
audio: ["-c:a", "libvo_amrwbenc", "-ar", "16000", "-ac", "1"],
encoders: ["h263", "libvo_amrwbenc"],
},
{
name: "Theora with Vorbis",
file: "theora.mkv",
video: ["-c:v", "libtheora"],
audio: ["-c:a", "libvorbis"],
encoders: ["libtheora", "libvorbis"],
},
{
name: "H.264 with MPEG audio layer 2",
file: "h264-mp2.mkv",
video: ["-c:v", "libx264"],
audio: ["-c:a", "mp2"],
encoders: ["libx264", "mp2"],
},
{
name: "H.264 with Windows Media audio 2",
file: "h264-wma.mkv",
video: ["-c:v", "libx264"],
audio: ["-c:a", "wmav2"],
encoders: ["libx264", "wmav2"],
},
];

let dir = "";
// Read when the file loads: skipIf decides before beforeAll runs.
const encoders = new Set(
Bun.spawnSync(["ffmpeg", "-hide_banner", "-encoders"])
.stdout.toString()
.split("\n")
.map((line) => line.trim().split(/\s+/)[1] ?? ""),
);

async function run(cmd: string[]): Promise<{ code: number; stderr: string }> {
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
const [code, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
return { code, stderr: stderr || stdout };
}

const available = (fixture: Fixture) =>
fixture.encoders.every((name) => encoders.has(name));

beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "legacy-codecs-"));
for (const fixture of FIXTURES) {
if (!available(fixture)) continue;
const made = await run([
"ffmpeg",
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"testsrc=d=1:s=176x144:r=25",
...(fixture.audio ? ["-f", "lavfi", "-i", "sine=d=1:r=44100"] : []),
...fixture.video,
...(fixture.audio ?? []),
join(dir, fixture.file),
]);
if (made.code !== 0)
throw new Error(`could not make ${fixture.file}: ${made.stderr}`);
}
}, 120_000);

afterAll(async () => {
stopProbeWorkers();
await rm(dir, { recursive: true, force: true });
});

describe("older formats a regular import can carry", () => {
for (const fixture of FIXTURES) {
test.skipIf(!available(fixture))(
`${fixture.name} probes and transcodes`,
Comment thread
richiemcilroy marked this conversation as resolved.
Resolved
async () => {
const input = join(dir, fixture.file);
const metadata = await probeVideoFile(input);
expect(metadata.width).toBe(176);
expect(metadata.height).toBe(144);

const output = join(dir, `${fixture.file}.mp4`);
const transcode = spawn({
cmd: [
"ffmpeg",
"-v",
"error",
"-y",
"-i",
input,
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-c:a",
"aac",
output,
],
stdout: "ignore",
stderr: "pipe",
});
const [code, stderr] = await Promise.all([
transcode.exited,
new Response(transcode.stderr).text(),
]);
expect(stderr).not.toContain("not on whitelist");
expect(code).toBe(0);

const streams = await run([
"ffprobe",
"-v",
"error",
"-show_entries",
"stream=codec_type,codec_name",
"-of",
"csv=p=0",
output,
]);
expect(streams.stderr).toContain("h264,video");
if (fixture.audio) expect(streams.stderr).toContain("aac,audio");
},
60_000,
);
}
});
31 changes: 22 additions & 9 deletions apps/media-server/src/__tests__/lib/media-input-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,21 @@ describe("decoder whitelist", () => {
"prores",
"mpeg4",
"mjpeg",
])
expect(isAllowedVideoCodec(codec)).toBe(true);
for (const codec of [
"mpeg1video",
"mpeg2video",
"wmv1",
"wmv2",
"wmv3",
"vc1",
"msmpeg4v1",
"msmpeg4v2",
"msmpeg4v3",
"flv1",
"h263",
"theora",
"dvvideo",
"cinepak",
"unknown",
])
expect(isAllowedVideoCodec(codec)).toBe(true);
for (const codec of ["dvvideo", "cinepak", "unknown"])
expect(isAllowedVideoCodec(codec)).toBe(false);
const decoders = new Set(DECODER_WHITELIST.split(","));
for (const name of [
Expand All @@ -123,16 +129,23 @@ describe("decoder whitelist", () => {
"mov_text",
"h264_cuvid",
"libdav1d",
"mpeg2video",
"msmpeg4",
"flv",
"mp2",
"wmav2",
"amrnb",
"adpcm_ms",
])
expect(decoders.has(name)).toBe(true);
for (const name of ["mpeg2video", "wmav2", "cinepak", "rawvideo"])
for (const name of ["cinepak", "rawvideo", "dvvideo", "indeo5"])
expect(decoders.has(name)).toBe(false);
});

test("FFmpeg refuses to decode a stream outside the list", async () => {
const dir = await mkdtemp(join(tmpdir(), "codec-whitelist-"));
try {
const path = join(dir, "mpeg2.mkv");
const path = join(dir, "cinepak.avi");
const make = spawn({
cmd: [
"ffmpeg",
Expand All @@ -143,7 +156,7 @@ describe("decoder whitelist", () => {
"-i",
"testsrc=d=0.2:s=64x64",
"-c:v",
"mpeg2video",
"cinepak",
"-y",
path,
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "probe-hardening-"));
const source = ["-f", "lavfi", "-i", "testsrc=d=1:s=128x96:r=10"];
await ffmpeg([...source, "-c:v", "mpeg4", join(dir, "part2.mp4")]);
await ffmpeg([...source, "-c:v", "mpeg2video", join(dir, "mpeg2.mkv")]);
await ffmpeg([...source, "-c:v", "cinepak", join(dir, "cinepak.mkv")]);
await ffmpeg([
...source,
"-f",
Expand Down Expand Up @@ -175,7 +175,7 @@ describe("probe input checks", () => {
}, 30_000);

test("a video codec outside the allowed list is refused", async () => {
const error = await probeError("mpeg2.mkv");
const error = await probeError("cinepak.mkv");
expect(error).toBeInstanceOf(UnreadableMediaInputError);
expect((error as Error).message).toContain("not supported");
}, 30_000);
Expand Down
39 changes: 39 additions & 0 deletions apps/media-server/src/lib/media-input-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,42 @@ export function getSafeDemuxerWhitelist(): string {
return safeDemuxerWhitelist;
}

// Regular Cap imports still carry these, and FFmpeg's decoders for them are
// mature. Exotic codecs (Cinepak, DV, Indeo, raw video) stay refused.
const LEGACY_VIDEO_CODECS = [
"mpeg1video",
"mpeg2video",
"wmv1",
"wmv2",
"wmv3",
"vc1",
"msmpeg4v1",
"msmpeg4v2",
"msmpeg4v3",
"flv1",
"h263",
"h263i",
"h263p",
"theora",
];

// FFmpeg opens these codecs with decoders of another name, and the decoder
// list is matched by decoder name.
const LEGACY_VIDEO_DECODERS = ["msmpeg4", "flv"];

const LEGACY_AUDIO_DECODERS = [
"mp2",
"mp2float",
"wmav1",
"wmav2",
"amrnb",
"amrwb",
"libopencore_amrnb",
"libopencore_amrwb",
"adpcm_ima_wav",
"adpcm_ms",
];

// Video codecs this server accepts in an upload, as ffprobe names them.
export const ALLOWED_VIDEO_CODECS: readonly string[] = [
"h264",
Expand All @@ -132,6 +168,7 @@ export const ALLOWED_VIDEO_CODECS: readonly string[] = [
"prores",
"mpeg4",
"mjpeg",
...LEGACY_VIDEO_CODECS,
];

export function isAllowedVideoCodec(codec: string): boolean {
Expand Down Expand Up @@ -206,6 +243,8 @@ export const ALLOWED_DECODERS: readonly string[] = [
"ac3_fixed",
"eac3",
"dca",
...LEGACY_VIDEO_DECODERS,
...LEGACY_AUDIO_DECODERS,
...PCM_DECODERS,
"mov_text",
"subrip",
Expand Down
Loading