Skip to content

[Bug]: validate-linked-issue route unreachable for maintainer sessions #734

Description

@philluiz2323

[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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    slopAI slop and/or attempts to game additional points via manipulation or alt profiles.

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions