diff --git a/scripts/ensure-docs-slide-pdf.js b/scripts/ensure-docs-slide-pdf.js index 90efe14af88..f9ae30951c3 100644 --- a/scripts/ensure-docs-slide-pdf.js +++ b/scripts/ensure-docs-slide-pdf.js @@ -12,11 +12,54 @@ const DOCS_DIR = path.join(ROOT, "docs"); const SOURCE_PATH = path.join(DOCS_DIR, "slides/github-agentic-workflows.pdf"); const OUTPUT_PATH = path.join(DOCS_DIR, "public/slides/github-agentic-workflows.pdf"); const LFS_POINTER_PREFIX = "version https://git-lfs.github.com/spec/v1"; +const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB +const TRUSTED_MEDIA_ORIGIN = "https://media.githubusercontent.com"; +const SLIDE_DECK_RELATIVE_PATH = "docs/slides/github-agentic-workflows.pdf"; -function isPdf(buffer) { +export function isPdf(buffer) { return buffer.subarray(0, 5).toString("utf8") === "%PDF-"; } +export function validatePdfBytes(buffer, sourceDescription) { + if (!Buffer.isBuffer(buffer)) { + throw new Error(`${sourceDescription} did not produce a Buffer.`); + } + if (buffer.length === 0) { + throw new Error(`${sourceDescription} is empty.`); + } + if (buffer.length > MAX_PDF_BYTES) { + throw new Error(`${sourceDescription} size ${buffer.length} exceeds limit of ${MAX_PDF_BYTES} bytes`); + } + if (!isPdf(buffer)) { + throw new Error(`${sourceDescription} is not a real PDF.`); + } + return buffer; +} + +export function isAllowedPdfContentType(contentType) { + const mediaType = contentType.split(";", 1)[0].trim().toLowerCase(); + return mediaType === "application/pdf" || mediaType === "application/octet-stream"; +} + +export function validateSlideDeckResponse(response) { + const contentType = response.headers.get("content-type") ?? ""; + if (!isAllowedPdfContentType(contentType)) { + throw new Error(`Unexpected content-type for slide deck: ${contentType}`); + } + + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const normalizedContentLength = contentLength.trim(); + if (!/^\d+$/.test(normalizedContentLength)) { + throw new Error(`Unexpected content-length for slide deck: ${contentLength}`); + } + const expectedBytes = Number(normalizedContentLength); + if (!Number.isSafeInteger(expectedBytes) || expectedBytes > MAX_PDF_BYTES) { + throw new Error(`Slide deck download size ${contentLength} exceeds limit of ${MAX_PDF_BYTES} bytes`); + } + } +} + function getRepositoryPath() { try { const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], { @@ -49,6 +92,27 @@ function getGitRef() { } } +export function buildSlideDeckUrl(repositoryPath, ref) { + // Validate each URL component before interpolating into the request URL. + // getGitRef() always returns a 40-character hex commit SHA (from GITHUB_SHA + // or `git rev-parse HEAD`). + const safeSHAPattern = /^[0-9a-f]{40}$/i; + if (!safeSHAPattern.test(ref)) { + throw new Error(`Unsafe git ref value: ${ref}`); + } + // Repository path must be exactly "owner/repo" with no dot-only path components. + const safeRepoPattern = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; + if (!safeRepoPattern.test(repositoryPath) || repositoryPath.split("/").some(p => /^[.]+$/.test(p))) { + throw new Error(`Unsafe repository path value: ${repositoryPath}`); + } + + const url = new URL(`/media/${repositoryPath}/${ref}/${SLIDE_DECK_RELATIVE_PATH}`, TRUSTED_MEDIA_ORIGIN); + if (url.origin !== TRUSTED_MEDIA_ORIGIN) { + throw new Error(`Unsafe slide deck origin: ${url.origin}`); + } + return url.toString(); +} + /** * Creates a minimal valid single-page PDF placeholder used when the real slide * deck cannot be fetched (e.g. in sandboxed dev/test environments without LFS @@ -93,7 +157,7 @@ function createPlaceholderPdfBytes() { async function readPdfBytes() { const bytes = fs.readFileSync(SOURCE_PATH); if (isPdf(bytes)) { - return bytes; + return validatePdfBytes(bytes, SOURCE_PATH); } if (!bytes.toString("utf8").startsWith(LFS_POINTER_PREFIX)) { @@ -102,21 +166,7 @@ async function readPdfBytes() { const ref = getGitRef(); const repositoryPath = getRepositoryPath(); - - // Validate each URL component before interpolating into the request URL. - // getGitRef() always returns a 40-character hex commit SHA (from GITHUB_SHA - // or `git rev-parse HEAD`). - const safeSHAPattern = /^[0-9a-f]{40}$/i; - if (!safeSHAPattern.test(ref)) { - throw new Error(`Unsafe git ref value: ${ref}`); - } - // Repository path must be exactly "owner/repo" with no dot-only path components. - const safeRepoPattern = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; - if (!safeRepoPattern.test(repositoryPath) || repositoryPath.split("/").some(p => p === "." || p === "..")) { - throw new Error(`Unsafe repository path value: ${repositoryPath}`); - } - - const url = `https://media.githubusercontent.com/media/${repositoryPath}/${ref}/docs/slides/github-agentic-workflows.pdf`; + const url = buildSlideDeckUrl(repositoryPath, ref); console.warn(`Detected Git LFS pointer at ${SOURCE_PATH}; downloading ${url}`); @@ -126,33 +176,13 @@ async function readPdfBytes() { throw new Error(`Failed to download slide deck PDF: ${response.status} ${response.statusText}`); } - // Validate the Content-Type header before consuming the body to ensure we - // are actually receiving a PDF and not arbitrary data. - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.startsWith("application/pdf") && !contentType.startsWith("application/octet-stream")) { - throw new Error(`Unexpected content-type for slide deck: ${contentType}`); - } - - // Guard against unexpectedly large downloads. - const MAX_BYTES = 50 * 1024 * 1024; // 50 MB - const contentLength = response.headers.get("content-length"); - if (contentLength !== null && Number(contentLength) > MAX_BYTES) { - throw new Error(`Slide deck download size ${contentLength} exceeds limit of ${MAX_BYTES} bytes`); - } + validateSlideDeckResponse(response); const downloadedBytes = Buffer.from(await response.arrayBuffer()); - if (downloadedBytes.length > MAX_BYTES) { - throw new Error(`Downloaded slide deck size ${downloadedBytes.length} exceeds limit of ${MAX_BYTES} bytes`); - } - - if (!isPdf(downloadedBytes)) { - throw new Error(`Downloaded slide deck from ${url} is not a real PDF.`); - } - - return downloadedBytes; + return validatePdfBytes(downloadedBytes, `Downloaded slide deck from ${url}`); } catch (error) { console.warn(`Warning: Could not download slide deck PDF (${error.message}). Using placeholder PDF.`); - return createPlaceholderPdfBytes(); + return validatePdfBytes(createPlaceholderPdfBytes(), "Placeholder slide deck"); } } @@ -168,7 +198,9 @@ async function main() { console.log(`✓ Slide PDF ready at ${OUTPUT_PATH}`); } -main().catch(error => { - console.error(error); - process.exit(1); -}); +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + main().catch(error => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/ensure-docs-slide-pdf.test.js b/scripts/ensure-docs-slide-pdf.test.js new file mode 100644 index 00000000000..69fc83afaeb --- /dev/null +++ b/scripts/ensure-docs-slide-pdf.test.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { buildSlideDeckUrl, isAllowedPdfContentType, isPdf, validatePdfBytes, validateSlideDeckResponse } from "./ensure-docs-slide-pdf.js"; + +const safeSha = "0123456789abcdef0123456789abcdef01234567"; + +function responseWithHeaders(headers) { + return { headers: new Headers(headers) }; +} + +function assertThrowsMatching(fn, pattern, testName) { + assert.throws(fn, pattern); + console.log(`PASS: ${testName}`); +} + +assert.equal(isPdf(Buffer.from("%PDF-1.4\n")), true); +assert.equal(isPdf(Buffer.from("not a pdf")), false); +console.log("PASS: detects PDF header"); + +assert.equal(isAllowedPdfContentType("application/pdf"), true); +assert.equal(isAllowedPdfContentType("application/pdf; charset=binary"), true); +assert.equal(isAllowedPdfContentType("application/octet-stream"), true); +assert.equal(isAllowedPdfContentType("text/html"), false); +console.log("PASS: accepts allowed and parameterized PDF content types"); + +validateSlideDeckResponse( + responseWithHeaders({ + "content-type": "application/pdf", + "content-length": "1024", + }) +); +console.log("PASS: accepts valid PDF response headers"); + +assertThrowsMatching(() => validateSlideDeckResponse(responseWithHeaders({ "content-type": "text/html" })), /Unexpected content-type/, "rejects non-PDF content types"); + +assertThrowsMatching(() => validateSlideDeckResponse(responseWithHeaders({ "content-type": "application/pdf", "content-length": "12x" })), /Unexpected content-length/, "rejects malformed content length"); + +assertThrowsMatching(() => validateSlideDeckResponse(responseWithHeaders({ "content-type": "application/pdf", "content-length": String(51 * 1024 * 1024) })), /exceeds limit/, "rejects oversized downloads before reading the body"); + +const pdfBytes = Buffer.from("%PDF-1.4\n%%EOF\n"); +assert.strictEqual(validatePdfBytes(pdfBytes, "test PDF"), pdfBytes); +console.log("PASS: accepts validated PDF bytes"); + +assertThrowsMatching(() => validatePdfBytes(Buffer.from(""), "test PDF"), /not a real PDF/, "rejects non-PDF bytes"); + +assert.equal(buildSlideDeckUrl("github/gh-aw", safeSha), `https://media.githubusercontent.com/media/github/gh-aw/${safeSha}/docs/slides/github-agentic-workflows.pdf`); +console.log("PASS: builds trusted slide deck URL"); + +assertThrowsMatching(() => buildSlideDeckUrl("github/../gh-aw", safeSha), /Unsafe repository path/, "rejects unsafe repository path"); + +assertThrowsMatching(() => buildSlideDeckUrl(".../gh-aw", safeSha), /Unsafe repository path/, "rejects dot-only repository path segments"); + +assertThrowsMatching(() => buildSlideDeckUrl("github/gh-aw", "main"), /Unsafe git ref/, "rejects non-SHA git ref"); + +console.log("All ensure-docs-slide-pdf tests passed.");