Skip to content

Commit b50d258

Browse files
authored
Enforce SEC-005 allowlist validation for workflow_dispatch target repo overrides (#27242)
1 parent 4f668af commit b50d258

2 files changed

Lines changed: 88 additions & 2 deletions

File tree

actions/setup/js/invocation_context_helpers.cjs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// @ts-check
22
/// <reference types="@actions/github-script" />
33

4-
const { parseRepoSlug: parseSharedRepoSlug } = require("./repo_helpers.cjs");
4+
const { parseRepoSlug: parseSharedRepoSlug, parseAllowedRepos, validateTargetRepo } = require("./repo_helpers.cjs");
5+
const { ERR_VALIDATION } = require("./error_codes.cjs");
56

67
/**
78
* @typedef {{ owner: string, repo: string }} RepoRef
@@ -106,6 +107,22 @@ function parseJSONPayload(value) {
106107
return null;
107108
}
108109

110+
/**
111+
* Validate workflow_dispatch target repository against allowlist configuration.
112+
* Enforces SEC-005 by rejecting disallowed cross-repository target overrides.
113+
* @param {RepoRef} workflowRepo
114+
* @param {RepoRef} targetRepo
115+
*/
116+
function checkAllowedRepo(workflowRepo, targetRepo) {
117+
const defaultRepo = `${workflowRepo.owner}/${workflowRepo.repo}`;
118+
const targetRepoSlug = `${targetRepo.owner}/${targetRepo.repo}`;
119+
const allowedRepos = parseAllowedRepos(process.env.GH_AW_ALLOWED_REPOS);
120+
const validation = validateTargetRepo(targetRepoSlug, defaultRepo, allowedRepos);
121+
if (!validation.valid) {
122+
throw new Error(`${ERR_VALIDATION}: ${validation.error}`);
123+
}
124+
}
125+
109126
/**
110127
* Resolve workflow repo and effective event context across invocation styles:
111128
* - native events
@@ -145,13 +162,17 @@ function resolveInvocationContext(rawContext) {
145162
if (inputs && typeof inputs === "object") {
146163
const inputsEventName = typeof inputs.event_name === "string" ? inputs.event_name : typeof inputs.eventName === "string" ? inputs.eventName : "";
147164
const parsedPayload = parseJSONPayload(inputs.event_payload) || parseJSONPayload(inputs.eventPayload);
165+
const targetRepo = parseRepoSlug(inputs.target_repo) || parseRepoSlug(inputs.targetRepo);
166+
if (targetRepo) {
167+
checkAllowedRepo(workflowRepo, targetRepo);
168+
}
148169
if (inputsEventName) {
149170
eventName = inputsEventName;
150171
}
151172
if (parsedPayload) {
152173
eventPayload = parsedPayload;
153174
}
154-
eventRepo = eventRepo || parseRepoSlug(inputs.event_repo) || parseRepoSlug(inputs.eventRepo) || parseRepoSlug(inputs.target_repo) || parseRepoSlug(inputs.targetRepo);
175+
eventRepo = eventRepo || parseRepoSlug(inputs.event_repo) || parseRepoSlug(inputs.eventRepo) || targetRepo;
155176
}
156177
}
157178

actions/setup/js/invocation_context_helpers.test.cjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,69 @@ describe("invocation_context_helpers", () => {
6767
expect(resolved.eventRepo).toEqual({ owner: "target-owner", repo: "target-repo" });
6868
expect(resolved.eventPayload.issue.number).toBe(777);
6969
});
70+
71+
it.each(["target_repo", "targetRepo"])("rejects workflow_dispatch %s when not in allowlist", targetRepoKey => {
72+
const originalAllowedRepos = process.env.GH_AW_ALLOWED_REPOS;
73+
try {
74+
process.env.GH_AW_ALLOWED_REPOS = "allowed-owner/allowed-repo";
75+
76+
expect(() =>
77+
resolveInvocationContext({
78+
eventName: "workflow_dispatch",
79+
repo: { owner: "side-owner", repo: "side-repo" },
80+
payload: {
81+
inputs: {
82+
[targetRepoKey]: "target-owner/target-repo",
83+
},
84+
},
85+
})
86+
).toThrow(/ERR_VALIDATION: Repository 'target-owner\/target-repo' is not in the allowed-repos list/);
87+
} finally {
88+
if (originalAllowedRepos === undefined) {
89+
delete process.env.GH_AW_ALLOWED_REPOS;
90+
} else {
91+
process.env.GH_AW_ALLOWED_REPOS = originalAllowedRepos;
92+
}
93+
}
94+
});
95+
96+
it("allows workflow_dispatch target_repo when it is in allowlist", () => {
97+
const originalAllowedRepos = process.env.GH_AW_ALLOWED_REPOS;
98+
try {
99+
process.env.GH_AW_ALLOWED_REPOS = "target-owner/target-repo";
100+
101+
const resolved = resolveInvocationContext({
102+
eventName: "workflow_dispatch",
103+
repo: { owner: "side-owner", repo: "side-repo" },
104+
payload: {
105+
inputs: {
106+
target_repo: "target-owner/target-repo",
107+
},
108+
},
109+
});
110+
111+
expect(resolved.eventRepo).toEqual({ owner: "target-owner", repo: "target-repo" });
112+
} finally {
113+
if (originalAllowedRepos === undefined) {
114+
delete process.env.GH_AW_ALLOWED_REPOS;
115+
} else {
116+
process.env.GH_AW_ALLOWED_REPOS = originalAllowedRepos;
117+
}
118+
}
119+
});
120+
121+
it("allows workflow_dispatch without target_repo inputs", () => {
122+
const resolved = resolveInvocationContext({
123+
eventName: "workflow_dispatch",
124+
repo: { owner: "side-owner", repo: "side-repo" },
125+
payload: {
126+
inputs: {
127+
event_name: "issues",
128+
},
129+
},
130+
});
131+
132+
expect(resolved.eventName).toBe("issues");
133+
expect(resolved.eventRepo).toEqual({ owner: "side-owner", repo: "side-repo" });
134+
});
70135
});

0 commit comments

Comments
 (0)