Skip to content

[BUG] Goals sync route interpolates unvalidated repo field into GitHub Search API query — query injection via stored data #1757

@nyxsky404

Description

@nyxsky404

Describe the bug

The goals sync endpoint (src/app/api/goals/sync/route.ts) reads a repo value from the database and interpolates it directly into a GitHub Search API query string without any validation or encoding:

// Line 83-85: repo comes from DB with no validation
const repo =
  (goal as any).repo ||
  (goal as any).repository ||
  (goal as any).repo_name ||
  null;

// Line 90: directly interpolated into the query
const repoQualifier = repo ? `+repo:${repo}` : "";

// Line 92-93: unsanitized value in URL
const ghRes = await fetch(
  `${GITHUB_API}/search/commits?q=author:${session.githubLogin}${repoQualifier}+author-date:${weekStart}..${weekEnd}&per_page=100&page=${page}`,
  ...
);

Problems:

  1. Query injection — If a malicious or malformed repo value is stored in the database (e.g., via a compromised admin panel, SQL injection elsewhere, or a migration bug), it can inject arbitrary qualifiers into the GitHub Search API query. For example, a repo value of owner/repo+author:victim would alter the search scope.

  2. Type safety bypass — The code uses (goal as any) to access three different possible column names (repo, repository, repo_name), completely bypassing TypeScript's type system. The .select() call on line 50 only selects "id, unit, repo, repository, repo_name" but there's no guarantee these columns exist or contain valid repository identifiers.

  3. No URL encoding — Even for legitimate repo names, special characters (spaces, +, #) would corrupt the query string since the value is not passed through encodeURIComponent().

  4. Inconsistent with contributions route — The contributions route (src/app/api/metrics/contributions/route.ts) uses URL and searchParams.set() for safe query construction, while this route uses raw string interpolation.

Expected behavior:

  • The repo value should be validated against a pattern like ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ before use
  • The query should be constructed using URL + searchParams (matching the pattern in the contributions route)
  • The (goal as any) casts should be replaced with a proper typed interface

Suggested fix:

function isValidRepoQualifier(repo: string): boolean {
  return /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo);
}

// ...
const repoQualifier = repo && isValidRepoQualifier(repo) ? ` repo:${repo}` : "";

const searchUrl = new URL(`${GITHUB_API}/search/commits`);
searchUrl.searchParams.set(
  "q",
  `author:${session.githubLogin}${repoQualifier} author-date:${weekStart}..${weekEnd}`
);
searchUrl.searchParams.set("per_page", "100");
searchUrl.searchParams.set("page", String(page));

File location: src/app/api/goals/sync/route.ts (lines 83–95)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions