fix: use original body from webhook payload for TOCTOU hardening (#904)

* fix: use original body from webhook payload for TOCTOU hardening

* test: add null originalBody + edited GraphQL body TOCTOU scenario
This commit is contained in:
David Dworken 2026-02-05 10:54:51 -08:00 committed by GitHub
parent 006aaf2935
commit f09dc9a6a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 275 additions and 4 deletions

View File

@ -71,6 +71,33 @@ export function extractOriginalTitle(
return undefined; return undefined;
} }
/**
* Extracts the original body from the GitHub webhook payload.
* This is the body as it existed when the trigger event occurred,
* preventing TOCTOU attacks where an attacker edits the body after
* the trigger but before the action reads it.
*
* @param context - Parsed GitHub context from webhook
* @returns The original body string, null (no body), or undefined (not available)
*/
export function extractOriginalBody(
context: ParsedGitHubContext,
): string | null | undefined {
if (isIssueCommentEvent(context)) {
return context.payload.issue?.body;
} else if (isPullRequestEvent(context)) {
return context.payload.pull_request?.body;
} else if (isPullRequestReviewEvent(context)) {
return context.payload.pull_request?.body;
} else if (isPullRequestReviewCommentEvent(context)) {
return context.payload.pull_request?.body;
} else if (isIssuesEvent(context)) {
return context.payload.issue?.body;
}
return undefined;
}
/** /**
* Filters comments to only include those that existed in their final state before the trigger time. * Filters comments to only include those that existed in their final state before the trigger time.
* This prevents malicious actors from editing comments after the trigger to inject harmful content. * This prevents malicious actors from editing comments after the trigger to inject harmful content.
@ -207,6 +234,7 @@ type FetchDataParams = {
triggerUsername?: string; triggerUsername?: string;
triggerTime?: string; triggerTime?: string;
originalTitle?: string; originalTitle?: string;
originalBody?: string | null;
includeCommentsByActor?: string; includeCommentsByActor?: string;
excludeCommentsByActor?: string; excludeCommentsByActor?: string;
}; };
@ -233,6 +261,7 @@ export async function fetchGitHubData({
triggerUsername, triggerUsername,
triggerTime, triggerTime,
originalTitle, originalTitle,
originalBody,
includeCommentsByActor, includeCommentsByActor,
excludeCommentsByActor, excludeCommentsByActor,
}: FetchDataParams): Promise<FetchDataResult> { }: FetchDataParams): Promise<FetchDataResult> {
@ -399,12 +428,21 @@ export async function fetchGitHubData({
body: c.body, body: c.body,
})); }));
// Add the main issue/PR body if it has content and wasn't edited after trigger // Use the original body from the webhook payload if provided (TOCTOU protection).
// This prevents a TOCTOU race condition where an attacker could edit the body // The webhook payload captures the body at event time, before any attacker edits.
// between when an authorized user triggered Claude and when Claude processes the request if (originalBody !== undefined) {
contextData.body = originalBody ?? "";
}
// Add the main issue/PR body if it has content and wasn't edited after trigger.
// When originalBody is provided, the body is already safe (from webhook payload).
// Otherwise, fall back to timestamp-based validation.
let mainBody: CommentWithImages[] = []; let mainBody: CommentWithImages[] = [];
if (contextData.body) { if (contextData.body) {
if (isBodySafeToUse(contextData, triggerTime)) { if (
originalBody !== undefined ||
isBodySafeToUse(contextData, triggerTime)
) {
mainBody = [ mainBody = [
{ {
...(isPR ...(isPR

View File

@ -12,6 +12,7 @@ import {
fetchGitHubData, fetchGitHubData,
extractTriggerTimestamp, extractTriggerTimestamp,
extractOriginalTitle, extractOriginalTitle,
extractOriginalBody,
} from "../../github/data/fetcher"; } from "../../github/data/fetcher";
import { createPrompt, generateDefaultPrompt } from "../../create-prompt"; import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
import { isEntityContext } from "../../github/context"; import { isEntityContext } from "../../github/context";
@ -79,6 +80,7 @@ export const tagMode: Mode = {
const triggerTime = extractTriggerTimestamp(context); const triggerTime = extractTriggerTimestamp(context);
const originalTitle = extractOriginalTitle(context); const originalTitle = extractOriginalTitle(context);
const originalBody = extractOriginalBody(context);
const githubData = await fetchGitHubData({ const githubData = await fetchGitHubData({
octokits: octokit, octokits: octokit,
@ -88,6 +90,7 @@ export const tagMode: Mode = {
triggerUsername: context.actor, triggerUsername: context.actor,
triggerTime, triggerTime,
originalTitle, originalTitle,
originalBody,
includeCommentsByActor: context.inputs.includeCommentsByActor, includeCommentsByActor: context.inputs.includeCommentsByActor,
excludeCommentsByActor: context.inputs.excludeCommentsByActor, excludeCommentsByActor: context.inputs.excludeCommentsByActor,
}); });

View File

@ -2,6 +2,7 @@ import { describe, expect, it, jest, test } from "bun:test";
import { import {
extractTriggerTimestamp, extractTriggerTimestamp,
extractOriginalTitle, extractOriginalTitle,
extractOriginalBody,
fetchGitHubData, fetchGitHubData,
filterCommentsToTriggerTime, filterCommentsToTriggerTime,
filterReviewsToTriggerTime, filterReviewsToTriggerTime,
@ -106,6 +107,55 @@ describe("extractOriginalTitle", () => {
}); });
}); });
describe("extractOriginalBody", () => {
it("should extract body from IssueCommentEvent on PR", () => {
const body = extractOriginalBody(mockPullRequestCommentContext);
expect(body).toBe("This PR fixes the memory leak issue reported in #788");
});
it("should extract body from PullRequestReviewEvent", () => {
const body = extractOriginalBody(mockPullRequestReviewContext);
expect(body).toBe(
"This PR improves error handling across all API endpoints",
);
});
it("should extract body from PullRequestReviewCommentEvent", () => {
const body = extractOriginalBody(mockPullRequestReviewCommentContext);
expect(body).toBe(
"This PR optimizes the search algorithm for better performance",
);
});
it("should extract body from pull_request event", () => {
const body = extractOriginalBody(mockPullRequestOpenedContext);
expect(body).toBe(
"## Summary\n\nThis PR adds JWT-based authentication to the API.\n\n## Changes\n\n- Added auth middleware\n- Added login endpoint\n- Added JWT token generation\n\n/claude please review the security aspects",
);
});
it("should extract body from issues event", () => {
const body = extractOriginalBody(mockIssueOpenedContext);
expect(body).toBe(
"## Description\n\nThe application crashes immediately after launching.\n\n## Steps to reproduce\n\n1. Install the app\n2. Launch it\n3. See crash\n\n/claude please help me fix this",
);
});
it("should return undefined for event without body", () => {
const context = createMockContext({
eventName: "issue_comment",
payload: {
comment: {
id: 123,
body: "test",
},
} as any,
});
const body = extractOriginalBody(context);
expect(body).toBeUndefined();
});
});
describe("filterCommentsToTriggerTime", () => { describe("filterCommentsToTriggerTime", () => {
const createMockComment = ( const createMockComment = (
createdAt: string, createdAt: string,
@ -1099,6 +1149,186 @@ describe("fetchGitHubData integration with time filtering", () => {
"Original Title (from webhook at trigger time)", "Original Title (from webhook at trigger time)",
); );
}); });
it("should use originalBody when provided instead of fetched body", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
pullRequest: {
number: 123,
title: "Test PR",
body: "Malicious body injected after trigger",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
additions: 10,
deletions: 5,
state: "OPEN",
commits: { totalCount: 1, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "123",
isPR: true,
triggerUsername: "trigger-user",
originalBody: "Original safe body from webhook",
});
expect(result.contextData.body).toBe("Original safe body from webhook");
});
it("should use fetched body when originalBody is not provided", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
pullRequest: {
number: 123,
title: "Test PR",
body: "Fetched body from GraphQL",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
additions: 10,
deletions: 5,
state: "OPEN",
commits: { totalCount: 1, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "123",
isPR: true,
triggerUsername: "trigger-user",
});
expect(result.contextData.body).toBe("Fetched body from GraphQL");
});
it("should use original body from webhook even if body was edited after trigger (TOCTOU prevention)", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
pullRequest: {
number: 123,
title: "Test PR",
body: "Malicious body (edited after trigger via GraphQL)",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
additions: 10,
deletions: 5,
state: "OPEN",
commits: { totalCount: 1, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "123",
isPR: true,
triggerUsername: "trigger-user",
triggerTime: "2024-01-15T12:00:00Z",
originalBody: "Original safe body (from webhook at trigger time)",
});
// Body should be from webhook, not the malicious GraphQL-fetched version
expect(result.contextData.body).toBe(
"Original safe body (from webhook at trigger time)",
);
});
it("should handle null originalBody by setting body to empty string", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
issue: {
number: 123,
title: "Test Issue",
body: "Some body from GraphQL",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
state: "OPEN",
labels: { nodes: [] },
comments: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "123",
isPR: false,
triggerUsername: "trigger-user",
originalBody: null,
});
// null originalBody means the issue had no body at trigger time
expect(result.contextData.body).toBe("");
});
it("should use null originalBody over malicious GraphQL body edited after trigger", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
issue: {
number: 123,
title: "Test Issue",
body: "Malicious body added after trigger",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
state: "OPEN",
labels: { nodes: [] },
comments: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "123",
isPR: false,
triggerUsername: "trigger-user",
triggerTime: "2024-01-15T12:00:00Z",
originalBody: null,
});
// Webhook says no body at trigger time — attacker-added GraphQL body must not be used
expect(result.contextData.body).toBe("");
});
}); });
describe("filterCommentsByActor", () => { describe("filterCommentsByActor", () => {