[Bug]: validate-linked-issue HTTP route is unreachable for non-operator maintainer sessions (allowlist omission)
Summary
The coarse session-path allowlist canSessionAccessPath gates which paths a
browser/session identity may even reach before a route handler runs. It lists
the sibling pre-submission tool check-before-start but omits
validate-linked-issue. Both route handlers contain the identical
requireSessionRepoAccess admit-path that is designed to let a verified
maintainer call the route for their own repo — but for validate-linked-issue
that admit-path is dead: the middleware short-circuits every non-operator session
with 403 insufficient_role before the handler runs.
Net effect: a verified maintainer of repo X, using the web UI / a session token,
can call check-before-start on X but gets a spurious 403 insufficient_role on
validate-linked-issue for the same repo X. Only operators
(ADMIN_GITHUB_LOGINS) and raw server-to-server API tokens (which bypass the
session allowlist entirely) can reach it. The MCP equivalent
(gittensory_validate_linked_issue) is unaffected — only the HTTP route is broken.
Evidence
// src/api/routes.ts — the session allowlist: check-before-start IS listed, validate-linked-issue is NOT
function canSessionAccessPath(env, identity, path): boolean {
if (isAuthorizedGitHubSessionLogin(env, identity.actor)) return true; // operators only
...
if (isRepoCheckBeforeStartPath(path)) return true; // ← sibling allowlisted
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
...
return false; // ← validate-linked-issue falls through
}
// There is no isRepoValidateLinkedIssuePath predicate and no /validate-linked-issue entry.
// src/api/routes.ts — the global middleware that consumes it
if (identity.kind === "session" && !canSessionAccessPath(c.env, identity, c.req.path))
return c.json({ error: "insufficient_role" }, 403); // ← non-operator session 403'd here, pre-handler
The two handlers are symmetric — both authenticate, load data, then admit a
maintainer session via requireSessionRepoAccess:
// validate-linked-issue (the broken one)
app.post("/v1/repos/:owner/:repo/validate-linked-issue", async (c) => {
...
if (identity.kind === "session") {
const forbidden = await requireSessionRepoAccess(c, identity, fullName, repo); // ← DEAD: never reached
if (forbidden) return forbidden;
}
return c.json(buildLinkedIssueValidation(...));
});
// check-before-start (works — IS allowlisted)
app.post("/v1/repos/:owner/:repo/check-before-start", async (c) => {
...
if (identity.kind === "session") {
const forbidden = await requireSessionRepoAccess(c, identity, fullName, repo);
if (forbidden) return forbidden;
}
return c.json(buildPreStartCheck(...));
});
The in-code contract for the allowlist makes the intent explicit:
canSessionAccessPath … only decides whether a session may REACH a path — the
per-route guards above enforce the actual identity/repo scope. A path added
here MUST be scoped by a per-route guard in its handler.
validate-linked-issue HAS the per-route guard (requireSessionRepoAccess) but
was never added to the allowlist — the inverse of the documented requirement,
leaving the guard unreachable.
Reachability
requiresApiToken("/v1/repos/o/r/validate-linked-issue") is true (it matches
/v1/), so the protected-route middleware runs. A session identity reaches the
canSessionAccessPath check, which returns false, → 403 insufficient_role.
Trigger: any non-operator maintainer calls POST /v1/repos/:owner/:repo/validate-linked-issue
with a browser/session token (the normal web-UI path). This is exactly the
maintainer audience the tool is built for.
Why it's wrong
validate-linked-issue and check-before-start are siblings introduced together
(#621/#622) with identical session-admission logic. The allowlist was updated for
one and not the other, so a legitimate maintainer-of-repo-X session is denied its
own repo's linked-issue validator while the twin tool works. The failure is
fail-closed (no over-exposure / security hole) but breaks a maintainer-facing
feature over the session/browser surface.
Suggested fix
Add the predicate and the allowlist entry, mirroring check-before-start exactly:
// in canSessionAccessPath, beside the check-before-start line:
if (isRepoValidateLinkedIssuePath(path)) return true;
function isRepoValidateLinkedIssuePath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/validate-linked-issue$/.test(path);
}
The per-route requireSessionRepoAccess guard already present in the handler then
enforces repo scope, so this exposes nothing a maintainer can't already reach via
check-before-start.
Test status
Not covered — and the suite gives false confidence. The integration tests exercise
validate-linked-issue only with (a) a raw API token (which bypasses
canSessionAccessPath entirely → passes), and (b) an unrelated session that
expects 403 — which passes for the wrong reason (blanket allowlist denial,
not the intended forbidden_repo per-repo denial; the test asserts only
status === 403, not the body). The one positive session test covers
check-before-start with an operator session, which is admitted regardless.
No test covers a non-operator maintainer session against validate-linked-issue,
so the asymmetry is invisible. A regression test should assert a repo-scoped
maintainer session gets 200 (not 403 insufficient_role).
Confidence note
High. Concrete, reachable, with the contract stated in-code; the fix is a
two-line mirror of the working sibling. The only nuance is severity: this is a
functionality/usability bug (legitimate access denied), not a security
vulnerability — it fails closed.
Distinct from prior reports
Unrelated to the predicted-gate parity work (#691/#717) or the gate-completion-403
finalize gap. This is a session-allowlist omission for one specific HTTP route,
distinct from any prior report.
[Bug]:
validate-linked-issueHTTP route is unreachable for non-operator maintainer sessions (allowlist omission)Summary
The coarse session-path allowlist
canSessionAccessPathgates which paths abrowser/session identity may even reach before a route handler runs. It lists
the sibling pre-submission tool
check-before-startbut omitsvalidate-linked-issue. Both route handlers contain the identicalrequireSessionRepoAccessadmit-path that is designed to let a verifiedmaintainer call the route for their own repo — but for
validate-linked-issuethat admit-path is dead: the middleware short-circuits every non-operator session
with
403 insufficient_rolebefore the handler runs.Net effect: a verified maintainer of repo X, using the web UI / a session token,
can call
check-before-starton X but gets a spurious403 insufficient_roleonvalidate-linked-issuefor the same repo X. Only operators(
ADMIN_GITHUB_LOGINS) and raw server-to-server API tokens (which bypass thesession allowlist entirely) can reach it. The MCP equivalent
(
gittensory_validate_linked_issue) is unaffected — only the HTTP route is broken.Evidence
The two handlers are symmetric — both authenticate, load data, then admit a
maintainer session via
requireSessionRepoAccess:The in-code contract for the allowlist makes the intent explicit:
validate-linked-issueHAS the per-route guard (requireSessionRepoAccess) butwas never added to the allowlist — the inverse of the documented requirement,
leaving the guard unreachable.
Reachability
requiresApiToken("/v1/repos/o/r/validate-linked-issue")istrue(it matches/v1/), so the protected-route middleware runs. A session identity reaches thecanSessionAccessPathcheck, which returnsfalse, →403 insufficient_role.Trigger: any non-operator maintainer calls
POST /v1/repos/:owner/:repo/validate-linked-issuewith a browser/session token (the normal web-UI path). This is exactly the
maintainer audience the tool is built for.
Why it's wrong
validate-linked-issueandcheck-before-startare siblings introduced together(#621/#622) with identical session-admission logic. The allowlist was updated for
one and not the other, so a legitimate maintainer-of-repo-X session is denied its
own repo's linked-issue validator while the twin tool works. The failure is
fail-closed (no over-exposure / security hole) but breaks a maintainer-facing
feature over the session/browser surface.
Suggested fix
Add the predicate and the allowlist entry, mirroring
check-before-startexactly:The per-route
requireSessionRepoAccessguard already present in the handler thenenforces repo scope, so this exposes nothing a maintainer can't already reach via
check-before-start.Test status
Not covered — and the suite gives false confidence. The integration tests exercise
validate-linked-issueonly with (a) a raw API token (which bypassescanSessionAccessPathentirely → passes), and (b) an unrelated session thatexpects
403— which passes for the wrong reason (blanket allowlist denial,not the intended
forbidden_repoper-repo denial; the test asserts onlystatus === 403, not the body). The one positive session test coverscheck-before-startwith an operator session, which is admitted regardless.No test covers a non-operator maintainer session against
validate-linked-issue,so the asymmetry is invisible. A regression test should assert a repo-scoped
maintainer session gets
200(not403 insufficient_role).Confidence note
High. Concrete, reachable, with the contract stated in-code; the fix is a
two-line mirror of the working sibling. The only nuance is severity: this is a
functionality/usability bug (legitimate access denied), not a security
vulnerability — it fails closed.
Distinct from prior reports
Unrelated to the predicted-gate parity work (#691/#717) or the gate-completion-403
finalize gap. This is a session-allowlist omission for one specific HTTP route,
distinct from any prior report.