[sec-check] collect-metrics.mjs githubAll() follows Link rel="next" to any origin, forwarding the GH_TOKEN Authorization header

Open Beginner friendly
#226 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
90/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
javascript, node.js
Domain
security

Research direction

Start in scripts/collect-metrics.mjs at githubAll() and review the Link rel="next" handling, then run node --test. Done means same-origin pagination still returns both pages while cross-origin, protocol-relative, downgrade, and unsupported targets are not followed, with the existing 55 tests passing.

Written by the indexing model from the issue text.

Description

agent/security hive/hosted-available-lke648397-260827-5n31

Security Finding

Severity: low (defence-in-depth / credential hygiene)
Type: unsafe-pattern (CWE-918 SSRF, CWE-522 credential transmission to untrusted origin)
File: scripts/collect-metrics.mjs — function githubAll()

githubAll() paginates by reading the Link: <...>; rel="next" response header and
fetching that URL with the same header object it was given:

async function githubAll(url, headers) {
  const results = [];
  let next = url;
  while (next) {
    const response = await fetch(next, { headers });
    ...
    const match = link.match(/<([^>]+)>;\s*rel="next"/);
    next = match ? match[1] : null;   // <-- origin never checked
  }
  return results;
}

Those headers come from makeGitHubHeaders(process.env.GH_TOKEN, ...) (line 85) and
therefore contain Authorization: Bearer <token> whenever GH_TOKEN is set.

The next-page target is taken from server-controlled response data and is used verbatim,
with no check that it still points at the origin the request was sent to. Node's fetch
strips Authorization across a cross-origin redirect, but this is not a redirect — the
script issues a brand-new authenticated request to whatever URL the header names, so no
stripping applies. A Link header naming https://evil.example.com/... (or the
protocol-relative //evil.example.com/..., or a plaintext http://api.github.com/...
downgrade) hands the bearer token to that host.

Impact

Any party able to influence the Link response header — a hostile/misconfigured proxy, a
GHES or GITHUB_API_URL substitution, or a TLS-terminating middlebox — can cause the build
to send GH_TOKEN to an arbitrary origin, or to resend it over plaintext HTTP.

Exposure today is limited: import-architectures.yml runs npm run collect:metrics without
GH_TOKEN in the step env, so CI currently paginates unauthenticated. That is incidental,
not a control — the script explicitly reads GH_TOKEN, maintainers run it locally with a PAT,
and issue #134 proposes wiring GH_TOKEN into exactly these workflows. Once that lands, CI
carries a real token through this loop.

Recommendation

Pin pagination to the origin of the initial request and refuse anything else. Replace the
next = match ? match[1] : null; line with an origin check:

async function githubAll(url, headers) {
  const origin = new URL(url).origin;
  const results = [];
  let next = url;
  while (next) {
    const response = await fetch(next, { headers });
    if (!response.ok) throw new Error(`GitHub API ${response.status}: ${next}`);
    results.push(...await response.json());
    const link = response.headers.get('link') || '';
    const match = link.match(/<([^>]+)>;\s*rel="next"/);
    next = match ? sameOriginNext(match[1], origin) : null;
  }
  return results;
}

// The Authorization header travels with every paginated request, so a Link
// header naming a different origin would hand the token to that host.  Only
// continue paginating within the origin the first request was sent to.
function sameOriginNext(candidate, origin) {
  let parsed;
  try {
    parsed = new URL(candidate, origin);
  } catch {
    console.warn(`Ignoring unparseable Link rel="next" target: ${candidate}`);
    return null;
  }
  if (parsed.origin !== origin) {
    console.warn(`Ignoring cross-origin Link rel="next" target: ${parsed.origin}`);
    return null;
  }
  return parsed.toString();
}

This keeps normal api.github.com pagination working (relative and absolute same-origin
targets both resolve), and is origin-relative rather than hardcoding api.github.com, so it
stays correct under GHES.

Verified against the patched source with a stubbed fetch:

Link rel="next" target followed?
https://api.github.com/...?page=2 yes (2 requests, both pages returned)
https://evil.example.com/x no
//evil.example.com/x no
http://api.github.com/x (TLS downgrade) no
javascript:alert(1) no
file:///etc/passwd no

node --test: 55/55 pass.

Scope

This is the only paginating fetch loop in the repo. scripts/lib/github.mjs (githubFetch)
and scripts/fetch-community-people.mjs issue single unpaginated requests and do not follow
Link, so they need no change. Fixing this issue therefore needs exactly one file changed.

— hive: agent=sec-check backend=copilot model=claude-opus-5

Dominant language
JavaScript
Stars
0
Forks
2
Avg merge
2d 22h
Merged PRs (30d)
12

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from cncf/endusers

All issues in cncf/endusers

Similar issues

More JavaScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.