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
4 changes: 2 additions & 2 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
"files": 12
},
"media-use": {
"hash": "332f69eaeabc945f",
"files": 156
"hash": "28948e640a5d133a",
"files": 158
},
"motion-graphics": {
"hash": "de8ec5464028ec67",
Expand Down
3 changes: 2 additions & 1 deletion skills/media-use/audio/scripts/lib/heygen.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
// heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
// pipeline. The credential resolver matches the hyperframes CLI auth: first
// usable source wins — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
Expand Down Expand Up @@ -125,11 +126,11 @@

// Download a (presigned) URL to destPath; returns byte length.
export async function downloadTo(url, destPath) {
const res = await fetch(url);
const res = await fetchMedia(url);
if (!res.ok) throw new Error(`download HTTP ${res.status}: ${String(url).slice(0, 80)}`);
const bytes = Buffer.from(await res.arrayBuffer());
mkdirSync(dirname(destPath), { recursive: true });
writeFileSync(destPath, bytes);

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
return bytes.length;
}

Expand Down
3 changes: 2 additions & 1 deletion skills/media-use/audio/scripts/lib/tts.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
// tts.mjs — multi-provider TTS for the media audio engine. The provider chain,
// auto-detected from env, is the one documented in ../SKILL.md:
//
Expand Down Expand Up @@ -212,7 +213,7 @@
function transcodeToWav(bytes, destWav) {
const td = mkdtempSync(join(tmpdir(), "hf-tts-"));
const tmp = join(td, "a.mp3");
writeFileSync(tmp, bytes);

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
mkdirSync(dirname(destWav), { recursive: true });
const ff = spawnSync(
"ffmpeg",
Expand Down Expand Up @@ -312,7 +313,7 @@
if (!inner.audio_url) {
return { ok: false, words: null, error: "HeyGen /voices/speech returned no audio_url" };
}
const res = await fetchImpl(inner.audio_url);
const res = await fetchMedia(inner.audio_url, { fetchImpl });
if (!res.ok) {
return { ok: false, words: null, error: `audio_url fetch failed: HTTP ${res.status}` };
}
Expand All @@ -329,7 +330,7 @@
}
} else {
mkdirSync(dirname(wavAbs), { recursive: true });
writeFileSync(wavAbs, bytes);

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
}
const words = Array.isArray(inner.word_timestamps)
? inner.word_timestamps
Expand Down
14 changes: 3 additions & 11 deletions skills/media-use/scripts/lib/freeze.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fetchMedia, isPublicMediaUrl } from "./media-fetch.mjs";
import { writeFileSync, copyFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";

Expand All @@ -7,7 +8,7 @@

export async function freezeUrl(url, destPath) {
const where = String(url).slice(0, 80);
const res = await fetch(url);
const res = await fetchMedia(url);
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${where}`);

// Fail fast on an advertised oversize body before reading a single byte.
Expand All @@ -30,7 +31,7 @@
if (total === 0) throw new Error(`freeze failed: empty response for ${where}`);

mkdirSync(dirname(destPath), { recursive: true });
writeFileSync(destPath, Buffer.concat(chunks, total));

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
return total;
}

Expand All @@ -47,15 +48,6 @@
/(^|\.)(youtube\.com|youtu\.be|vimeo\.com|tiktok\.com|instagram\.com|twitter\.com|x\.com|facebook\.com|dailymotion\.com)$/i;
const MEDIA_EXT = /\.(mp3|wav|m4a|aac|ogg|flac|mp4|mov|webm|mkv|png|jpe?g|webp|gif|svg|avif)$/i;

// SSRF guard (m11): a user-supplied --from URL must not point at the local host
// or a private network. Blocks loopback/localhost, RFC1918, link-local, and the
// IPv6 equivalents on the literal hostname.
// ponytail: literal-host check only; a DNS name that *resolves* to a private IP
// (rebinding) still passes — add resolve-then-check if --from ever fetches from
// untrusted hostnames at scale.
const PRIVATE_HOST =
/^(localhost|.*\.local|.*\.internal|127\.|10\.|0\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|\[?(::1|::ffff:127\.|f[cd][0-9a-f]{2}:|fe80:))/i;

export function isDirectMediaUrl(u) {
let url;
try {
Expand All @@ -65,6 +57,6 @@
}
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
if (PLATFORM_HOSTS.test(url.hostname)) return false;
if (PRIVATE_HOST.test(url.hostname)) return false;
if (!isPublicMediaUrl(url)) return false;
return MEDIA_EXT.test(url.pathname);
}
50 changes: 30 additions & 20 deletions skills/media-use/scripts/lib/heygen-video-provider.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async function freshGenerate() {
return module.heygenVideoGenerate;
}

async function listenVideoServer() {
async function listenVideoServer(t) {
const server = http.createServer((req, res) => {
if (req.url !== "/video.mp4") {
res.writeHead(404).end();
Expand All @@ -51,23 +51,33 @@ async function listenVideoServer() {
await new Promise((resolve) => server.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
server,
url: `http://127.0.0.1:${address.port}/video.mp4`,
};
// Keep the provider URL public; only the test transport reaches the local fixture.
const url = "https://media.example.com/video.mp4";
const originalFetch = globalThis.fetch;
t.mock.method(globalThis, "fetch", (requested, options) => {
assert.equal(requested, url);
assert.equal(options.redirect, "manual");
return originalFetch(`http://127.0.0.1:${address.port}/video.mp4`, options);
});
return { server, url };
}

async function listenFailingVideoServer() {
async function listenFailingVideoServer(t) {
const server = http.createServer((req, res) => {
res.writeHead(500).end();
});
await new Promise((resolve) => server.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
server,
url: `http://127.0.0.1:${address.port}/video.mp4`,
};
// Keep the provider URL public; only the test transport reaches the local fixture.
const url = "https://media.example.com/video.mp4";
const originalFetch = globalThis.fetch;
t.mock.method(globalThis, "fetch", (requested, options) => {
assert.equal(requested, url);
assert.equal(options.redirect, "manual");
return originalFetch(`http://127.0.0.1:${address.port}/video.mp4`, options);
});
return { server, url };
}

function closeServer(server) {
Expand Down Expand Up @@ -144,8 +154,8 @@ function bodyFromInvocation(invocation) {
return JSON.parse(invocation.slice(start + marker.length));
}

test("downloads a generated avatar video and returns the generated MP4 result", async () => {
const { server, url } = await listenVideoServer();
test("downloads a generated avatar video and returns the generated MP4 result", async (t) => {
const { server, url } = await listenVideoServer(t);
let localPath;
try {
await withFakeHeygen(
Expand Down Expand Up @@ -191,8 +201,8 @@ test("downloads a generated avatar video and returns the generated MP4 result",
}
});

test("tags video creation but not avatar or voice discovery", async () => {
const { server, url } = await listenVideoServer();
test("tags video creation but not avatar or voice discovery", async (t) => {
const { server, url } = await listenVideoServer(t);
let localPath;
try {
await withFakeHeygen(
Expand All @@ -218,8 +228,8 @@ test("tags video creation but not avatar or voice discovery", async () => {
}
});

test("uses explicit avatar and voice overrides without discovery", async () => {
const { server, url } = await listenVideoServer();
test("uses explicit avatar and voice overrides without discovery", async (t) => {
const { server, url } = await listenVideoServer(t);
let localPath;
try {
await withFakeHeygen(
Expand Down Expand Up @@ -249,8 +259,8 @@ test("uses explicit avatar and voice overrides without discovery", async () => {
}
});

test("caches discovered avatar and voice IDs for the process", async () => {
const { server, url } = await listenVideoServer();
test("caches discovered avatar and voice IDs for the process", async (t) => {
const { server, url } = await listenVideoServer(t);
const localPaths = new Set();
try {
await withFakeHeygen(
Expand Down Expand Up @@ -349,8 +359,8 @@ test("onboards and returns null when avatar/voice discovery itself is unauthenti
});
});

test("download failure after a successful create returns null and logs a diagnostic", async () => {
const { server, url } = await listenFailingVideoServer();
test("download failure after a successful create returns null and logs a diagnostic", async (t) => {
const { server, url } = await listenFailingVideoServer(t);
try {
await withFakeHeygen({ response: JSON.stringify({ data: { video_url: url } }) }, async () => {
const heygenVideoGenerate = await freshGenerate();
Expand Down
7 changes: 4 additions & 3 deletions skills/media-use/scripts/lib/logo-provider.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fetchMedia } from "./media-fetch.mjs";
// Official brand marks — the `logo` type's provider tiers, tried in registry
// order. Every tier was verified against a 54-brand stress test (2026-07,
// 100% cascade hit). Hit counts below are a snapshot of that run — they
Expand Down Expand Up @@ -105,13 +106,13 @@
}

async function fetchJson(url) {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const res = await fetchMedia(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
return res.json();
}

async function urlExists(url) {
const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(10_000) });
const res = await fetchMedia(url, { method: "HEAD", signal: AbortSignal.timeout(10_000) });
return res.ok;
}

Expand Down Expand Up @@ -196,7 +197,7 @@
const url = `https://icons.duckduckgo.com/ip3/${domain}.ico`;
let body;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const res = await fetchMedia(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
body = Buffer.from(await res.arrayBuffer());
} catch {
Expand All @@ -208,7 +209,7 @@
// gets frozen and the favicon tier costs one network round-trip, not two.
const bytes = body.byteLength;
const tmp = join(mkdtempSync(join(tmpdir(), "media-use-logo-")), `${domain}.ico`);
writeFileSync(tmp, body);

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
return {
localPath: tmp,
ext: ".ico",
Expand Down
17 changes: 17 additions & 0 deletions skills/media-use/scripts/lib/logo-provider.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,20 @@ test("the real logo cascade falls through tier by tier to the first hit", async
assert.ok(res, "cascade must land on the favicon tier");
assert.equal(res.metadata.provider, "favicon.ddg");
});

for (const search of [simpleIconsSearch, githubAvatarSearch]) {
test(`${search.name} rejects private HEAD redirects`, async (t) => {
const seen = [];
t.mock.method(globalThis, "fetch", async (url, options) => {
seen.push(url);
assert.equal(options.method, "HEAD");
assert.ok(options.signal);
return options.redirect === "manual"
? new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } })
: new Response(null, { status: 200 });
});
assert.equal(await search("vercel logo"), null);
assert.equal(seen.length, 1);
assert.ok(!seen[0].includes("127.0.0.1"));
});
}
71 changes: 71 additions & 0 deletions skills/media-use/scripts/lib/media-fetch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Media downloads use public HTTP(S) URLs. Validate every redirect target;
// a provider result must meet the same host policy as a direct ingest URL.
// Public HTTPS-to-HTTP redirects are allowed, matching direct HTTP support.
// This is a literal-host policy, not DNS pinning: DNS resolution remains trusted.

import { BlockList, isIP } from "node:net";

const blocked = new BlockList();
for (const [network, prefix] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.88.99.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
])
blocked.addSubnet(network, prefix, "ipv4");
for (const [network, prefix] of [
["::", 128],
["::1", 128],
["fc00::", 7],
["fe80::", 10],
["fec0::", 10],
["ff00::", 8],
["2001:db8::", 32],
])
blocked.addSubnet(network, prefix, "ipv6");

export function isPublicMediaUrl(value) {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
const host = url.hostname.replace(/\.$/, "");
if (
host === "localhost" ||
host.endsWith(".localhost") ||
host.endsWith(".local") ||
host.endsWith(".internal")
)
return false;
const address = host.replace(/^\[|\]$/g, "");
const family = isIP(address);
return family === 0 || !blocked.check(address, family === 4 ? "ipv4" : "ipv6");
} catch {
return false;
}
}

export async function fetchMedia(url, { method = "GET", signal, fetchImpl = fetch } = {}) {
let current = String(url);
for (let hop = 0; hop <= 5; hop++) {
if (!isPublicMediaUrl(current))
throw new Error("Media download blocked: URL is not public HTTP(S)");
const response = await fetchImpl(current, { method, signal, redirect: "manual" });
if (!(response.status >= 300 && response.status < 400)) return response;
const location = response.headers.get("location");
if (!location) return response;
await response.body?.cancel();
current = new URL(location, current).href;
}
throw new Error("Media download exceeded redirect limit");
}
Loading
Loading