Skip to content
Draft
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
45 changes: 32 additions & 13 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 54 additions & 2 deletions src/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ describe("CoderAgentChatAction", () => {
// `https://attacker.example/coder/coder/issues/1` would have
// called `octokit.rest.issues.createComment` with owner=coder,
// repo=coder, number=1 under the workflow's `github-token`. The
// action now refuses.
// host is now anchored to `GITHUB_SERVER_URL` (default
// `https://github.com`), so a different host is refused.
const inputs = createMockInputs({
githubURL: "https://code.acme.com/owner/repo/issues/123",
});
Expand All @@ -188,7 +189,7 @@ describe("CoderAgentChatAction", () => {
);

expect(() => action.parseGithubURL()).toThrowError(
/non-github.com hosts/,
/current GitHub server/,
);
});

Expand All @@ -206,6 +207,57 @@ describe("CoderAgentChatAction", () => {
/Invalid `github-url` input/,
);
});
test("accepts a GHES URL when GITHUB_SERVER_URL points at that host", () => {
const previous = process.env.GITHUB_SERVER_URL;
process.env.GITHUB_SERVER_URL = "https://github.example.com";
try {
const inputs = createMockInputs({
githubURL: "https://github.example.com/owner/repo/pull/7",
});
const action = new CoderAgentChatAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);

expect(action.parseGithubURL()).toEqual({
githubOrg: "owner",
githubRepo: "repo",
githubIssueNumber: 7,
});
} finally {
if (previous === undefined) {
delete process.env.GITHUB_SERVER_URL;
} else {
process.env.GITHUB_SERVER_URL = previous;
}
}
});

test("rejects a dotcom URL when GITHUB_SERVER_URL points at a GHES host", () => {
const previous = process.env.GITHUB_SERVER_URL;
process.env.GITHUB_SERVER_URL = "https://github.example.com";
try {
const inputs = createMockInputs({
githubURL: "https://github.com/owner/repo/issues/1",
});
const action = new CoderAgentChatAction(
coderClient,
octokit as unknown as Octokit,
inputs,
);

expect(() => action.parseGithubURL()).toThrowError(
/current GitHub server/,
);
} finally {
if (previous === undefined) {
delete process.env.GITHUB_SERVER_URL;
} else {
process.env.GITHUB_SERVER_URL = previous;
}
}
});
});

describe("generateChatUrl", () => {
Expand Down
31 changes: 25 additions & 6 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
buildFailureCommentBody,
buildSuccessCommentBody,
classifyError,
DEFAULT_GITHUB_SERVER_URL,
deriveCommentKey,
type FailureDetail,
normalizeBaseUrl,
Expand Down Expand Up @@ -112,13 +113,15 @@ export class CoderAgentChatAction {
throw new Error("Missing GitHub URL");
}

const parsed = parseGithubItemURL(this.inputs.githubURL);
const serverURL = this.githubServerURL();
const parsed = parseGithubItemURL(this.inputs.githubURL, serverURL);
if (!parsed) {
throw new Error(
`Invalid \`github-url\` input "${this.inputs.githubURL}". ` +
"Expected `https://github.com/<owner>/<repo>/issues/<n>` or " +
"`https://github.com/<owner>/<repo>/pull/<n>`. The action " +
"rejects non-github.com hosts so a workflow that templates " +
`Expected \`${serverURL}/<owner>/<repo>/issues/<n>\` or ` +
`\`${serverURL}/<owner>/<repo>/pull/<n>\`. The action rejects ` +
"hosts other than the current GitHub server " +
"(`GITHUB_SERVER_URL`) so a workflow that templates " +
"user-controlled content into this input cannot redirect the " +
"action to an attacker-chosen repository.",
);
Expand All @@ -130,6 +133,14 @@ export class CoderAgentChatAction {
};
}

// The GitHub server the action runs against. The Actions runner sets
// `GITHUB_SERVER_URL` (e.g. `https://github.com` on dotcom,
// `https://github.example.com` on GHES). Falls back to dotcom outside a
// runner. Read at this edge so `comment.ts` helpers stay pure.
private githubServerURL(): string {
return process.env.GITHUB_SERVER_URL || DEFAULT_GITHUB_SERVER_URL;
}

/**
* Generate chat URL.
*/
Expand Down Expand Up @@ -158,7 +169,11 @@ export class CoderAgentChatAction {
// stays pure and tests stay deterministic.
const workflow = process.env.GITHUB_WORKFLOW || undefined;
const marker = buildCommentMarker(
deriveCommentKey({ ...this.inputs, workflow }),
deriveCommentKey({
...this.inputs,
workflow,
serverURL: this.githubServerURL(),
}),
);
const diff = args.chat?.diff_status;
const hasPR = diff?.pr_number != null;
Expand Down Expand Up @@ -514,7 +529,11 @@ export class CoderAgentChatAction {
// stays pure and tests stay deterministic.
const workflow = process.env.GITHUB_WORKFLOW || undefined;
const marker = buildCommentMarker(
deriveCommentKey({ ...this.inputs, workflow }),
deriveCommentKey({
...this.inputs,
workflow,
serverURL: this.githubServerURL(),
}),
);
const body = buildFailureCommentBody(detail, {
agentsUrl: buildDeploymentAgentsUrl(this.inputs.coderURL),
Expand Down
97 changes: 97 additions & 0 deletions src/comment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type FailureDetail,
findCommentByPredicate,
normalizeBaseUrl,
parseGithubItemURL,
renderDetailBlock,
type SuccessCommentContext,
} from "./comment";
Expand Down Expand Up @@ -39,6 +40,93 @@ describe("buildCommentMarker", () => {
});
});

describe("parseGithubItemURL", () => {
test("parses a dotcom issue URL under the default server", () => {
expect(
parseGithubItemURL("https://github.com/owner/repo/issues/123"),
).toEqual({ owner: "owner", repo: "repo", number: 123 });
});

test("parses a dotcom PR URL under the default server", () => {
expect(parseGithubItemURL("https://github.com/owner/repo/pull/42")).toEqual(
{ owner: "owner", repo: "repo", number: 42 },
);
});

test("tolerates a trailing slash, query string, and fragment", () => {
expect(
parseGithubItemURL("https://github.com/owner/repo/pull/42/?tab=files"),
).toEqual({ owner: "owner", repo: "repo", number: 42 });
expect(
parseGithubItemURL(
"https://github.com/owner/repo/issues/7#issuecomment-1",
),
).toEqual({ owner: "owner", repo: "repo", number: 7 });
});

test("rejects extra path segments", () => {
expect(
parseGithubItemURL("https://github.com/owner/repo/issues/123/files"),
).toBeUndefined();
});

test("parses a GHES URL when serverURL matches (host anchoring)", () => {
expect(
parseGithubItemURL(
"https://github.example.com/owner/repo/pull/1",
"https://github.example.com",
),
).toEqual({ owner: "owner", repo: "repo", number: 1 });
});

test("tolerates a trailing slash on serverURL", () => {
expect(
parseGithubItemURL(
"https://github.example.com/owner/repo/pull/1",
"https://github.example.com/",
),
).toEqual({ owner: "owner", repo: "repo", number: 1 });
});

test("rejects a host that does not match serverURL (security case)", () => {
// A URL on a different host than the runner's GITHUB_SERVER_URL must
// not parse, so user-controlled github-url cannot redirect the action
// to an attacker-chosen host.
expect(
parseGithubItemURL(
"https://attacker.example/owner/repo/issues/1",
"https://github.example.com",
),
).toBeUndefined();
});

test("rejects a dotcom URL when serverURL is a GHES host", () => {
expect(
parseGithubItemURL(
"https://github.com/owner/repo/issues/1",
"https://github.example.com",
),
).toBeUndefined();
});

test("does not treat the server host as a regex pattern", () => {
// The dots in the host are metacharacters; RegExp.escape must keep
// them literal so a host that merely matches the pattern (dot as
// wildcard) is still rejected.
expect(
parseGithubItemURL(
"https://githubXexample.com/owner/repo/issues/1",
"https://github.example.com",
),
).toBeUndefined();
});

test("returns undefined for empty input", () => {
expect(parseGithubItemURL(undefined)).toBeUndefined();
expect(parseGithubItemURL("")).toBeUndefined();
});
});

describe("deriveCommentKey", () => {
test("uses idempotencyKey when set", () => {
expect(
Expand Down Expand Up @@ -77,6 +165,15 @@ describe("deriveCommentKey", () => {
).toBe("https://code.acme.com/owner/repo/issues/42");
});

test("derives <owner>/<repo>#<number> from a GHES URL when serverURL matches", () => {
expect(
deriveCommentKey({
githubURL: "https://github.example.com/owner/repo/issues/123",
serverURL: "https://github.example.com",
}),
).toBe("owner/repo#123");
});

test("appends workflow suffix to the derived per-target key", () => {
expect(
deriveCommentKey({
Expand Down
Loading