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
3 changed files with 275 additions and 4 deletions
+42 -4
View File
@@ -71,6 +71,33 @@ export function extractOriginalTitle(
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.
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
@@ -207,6 +234,7 @@ type FetchDataParams = {
triggerUsername?: string;
triggerTime?: string;
originalTitle?: string;
originalBody?: string | null;
includeCommentsByActor?: string;
excludeCommentsByActor?: string;
};
@@ -233,6 +261,7 @@ export async function fetchGitHubData({
triggerUsername,
triggerTime,
originalTitle,
originalBody,
includeCommentsByActor,
excludeCommentsByActor,
}: FetchDataParams): Promise<FetchDataResult> {
@@ -399,12 +428,21 @@ export async function fetchGitHubData({
body: c.body,
}));
// Add the main issue/PR body if it has content and wasn't edited after trigger
// This prevents a TOCTOU race condition where an attacker could edit the body
// between when an authorized user triggered Claude and when Claude processes the request
// Use the original body from the webhook payload if provided (TOCTOU protection).
// The webhook payload captures the body at event time, before any attacker edits.
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[] = [];
if (contextData.body) {
if (isBodySafeToUse(contextData, triggerTime)) {
if (
originalBody !== undefined ||
isBodySafeToUse(contextData, triggerTime)
) {
mainBody = [
{
...(isPR
+3
View File
@@ -12,6 +12,7 @@ import {
fetchGitHubData,
extractTriggerTimestamp,
extractOriginalTitle,
extractOriginalBody,
} from "../../github/data/fetcher";
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
import { isEntityContext } from "../../github/context";
@@ -79,6 +80,7 @@ export const tagMode: Mode = {
const triggerTime = extractTriggerTimestamp(context);
const originalTitle = extractOriginalTitle(context);
const originalBody = extractOriginalBody(context);
const githubData = await fetchGitHubData({
octokits: octokit,
@@ -88,6 +90,7 @@ export const tagMode: Mode = {
triggerUsername: context.actor,
triggerTime,
originalTitle,
originalBody,
includeCommentsByActor: context.inputs.includeCommentsByActor,
excludeCommentsByActor: context.inputs.excludeCommentsByActor,
});