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
120 changes: 76 additions & 44 deletions scripts/ensure-docs-slide-pdf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"], {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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}`);

Expand All @@ -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");
}
}

Expand All @@ -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);
});
}
56 changes: 56 additions & 0 deletions scripts/ensure-docs-slide-pdf.test.js
Original file line number Diff line number Diff line change
@@ -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("<html></html>"), "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.");