Skip to content

fix: validate repo field before GitHub query construction in goals sync#1783

Merged
Priyanshu-byte-coder merged 1 commit into
Priyanshu-byte-coder:mainfrom
Ridanshi:fix/goals-sync-repo-query-injection
May 31, 2026
Merged

fix: validate repo field before GitHub query construction in goals sync#1783
Priyanshu-byte-coder merged 1 commit into
Priyanshu-byte-coder:mainfrom
Ridanshi:fix/goals-sync-repo-query-injection

Conversation

@Ridanshi
Copy link
Copy Markdown
Contributor

Closes #1757

Problem

POST /api/goals/sync reads an optional repository filter from stored goal rows and interpolated the raw value directly into a GitHub Search API URL without any validation:

const repo =
  (goal as any).repo ||
  (goal as any).repository ||
  (goal as any).repo_name ||
  null;

const repoQualifier = repo ? `+repo:${repo}` : "";

fetch(`${GITHUB_API}/search/commits?q=author:${session.githubLogin}${repoQualifier}+author-date:…`)

A stored value containing GitHub Search qualifiers — for example octocat/Hello-World+author:victim — would be interpolated verbatim into the query string:

?q=author:alice+repo:octocat/Hello-World+author:victim+author-date:…

This expands the search scope beyond the authenticated user's commits, counting commits from victim toward the goal's progress total. The same pattern applies to any qualifier that the GitHub Search API recognises (language:, org:, is:, etc.).

Root cause

Three compounding problems in src/app/api/goals/sync/route.ts:

  1. No validation of the stored repo value before use.
  2. Raw string concatenation in the URL construction, making the query boundary structurally unclear.
  3. (goal as any) throughout — bypasses TypeScript and obscures that three differently-named fields all feed the same code path.

Fix

Validation — extractValidRepoFromGoal()

Validates the stored value against a strict owner/repo regex before any use:

REPO_IDENTIFIER_RE = /^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?)\/([a-zA-Z0-9._-]{1,100})$/
  • owner: 1–39 chars, alphanumeric + hyphens, cannot start/end with a hyphen
  • repo: 1–100 chars, alphanumeric, dots, hyphens, underscores
  • exactly one slash, no extra segments or operators
  • additionally rejects . and .. as repo names

Any value that does not match is treated as absent — the repo qualifier is simply omitted from the query and sync proceeds without a repository filter. The function is exported so it can be unit-tested independently.

Safe query construction

Moved all GitHub Search URL construction to URLSearchParams:

const qParts = [`author:${session.githubLogin}`];
if (repo) qParts.push(`repo:${repo}`);   // repo is already validated
qParts.push(`author-date:${weekStart}..${weekEnd}`);

const commitSearchParams = new URLSearchParams({
  q: qParts.join(" "),
  per_page: "100",
  page: String(page),
});

URLSearchParams encodes the entire q string as a single value, so the parameter boundary is structurally enforced.

Type safety

Replaced (goal as any) with a typed ActivityGoal interface:

interface ActivityGoal {
  id: string;
  unit: string;
  repo: string | null;
  repository: string | null;
  repo_name: string | null;
}

Tests — test/goals-sync-repo-validation.test.ts (28 new tests)

Category Tests
Valid inputs Standard, dots/underscores, hyphens, all three field names, whitespace trimming, max lengths
Null/empty All-null fields, empty string, whitespace-only
Query injection (regression #1757) +author:victim, space-separated qualifier, &, URL-encoded %2B
Extra path segments Three-segment (owner/repo/issues), four-segment
Path traversal owner/.., owner/.
Invalid formats Bare name, leading/trailing slash, hyphen violations, owner >39, repo >100
Field priority repo > repository > repo_name; invalid first field does not fall through to valid second

All 28 pass. The one pre-existing failure in test/dateUtils.test.ts is a timezone boundary issue unrelated to this change.

Root cause
----------
The goals sync route read an optional repository filter from stored goal
rows and interpolated the raw value directly into a GitHub Search API
URL:

  const repo =
    (goal as any).repo ||
    (goal as any).repository ||
    (goal as any).repo_name ||
    null;

  const repoQualifier = repo ? `+repo:${repo}` : "";
  fetch(`…/search/commits?q=author:${login}${repoQualifier}+author-date:…`)

A stored value such as "octocat/Hello-World+author:victim" would inject
an additional author qualifier into the GitHub Search API query, silently
expanding the commit search beyond the authenticated user's own history
and producing incorrect goal progress counts.

Additionally, `(goal as any)` bypassed TypeScript type safety across all
three field name variants, and the raw query string used `+` concatenation
instead of URLSearchParams, making the parameter boundary unclear.

Fix
---

src/app/api/goals/sync/route.ts

- Introduce REPO_IDENTIFIER_RE — a strict regex accepting only
  "owner/repo" where owner is 1–39 chars (alphanumeric + hyphens,
  no leading/trailing hyphens) and repo is 1–100 chars (alphanumeric,
  dots, hyphens, underscores). The same constraint previously applied
  to the public repo-analytics endpoint.

- Export extractValidRepoFromGoal(goal: ActivityGoal): string | null
  which reads the first non-null value from goal.repo → goal.repository
  → goal.repo_name, trims it, validates it against the regex, and
  additionally rejects "." and ".." as repo names. Any value that does
  not match returns null so the repo filter is omitted from the query.

- Define the ActivityGoal typed interface to replace (goal as any),
  documenting the three optional field names and providing proper type
  coverage for downstream code.

- Move GitHub Search URL construction to URLSearchParams so the combined
  qualifier string is encoded as a single atomic value and cannot be
  split by embedded special characters. Applied to both the commit
  search loop and the PR search call.

test/goals-sync-repo-validation.test.ts — 28 new tests:
  Valid inputs: standard, dots/underscores, org hyphens, all three
    field names, whitespace trimming, max lengths
  Null/empty: all-null, empty string, whitespace-only
  Query injection (regression for Priyanshu-byte-coder#1757): embedded +author:, space-
    separated qualifier, ampersand, URL-encoded %2B
  Extra path segments: three-segment, four-segment paths
  Path traversal: owner/.., owner/.
  Invalid formats: bare name, leading/trailing slash, hyphen rules,
    owner >39 chars, repo >100 chars
  Field priority: repo > repository > repo_name; invalid higher-
    priority field does not fall through to a valid lower-priority field

Closes Priyanshu-byte-coder#1757
@vercel
Copy link
Copy Markdown

vercel Bot commented May 31, 2026

@Ridanshi is attempting to deploy a commit to the PRIYANSHU DOSHI's projects Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added gssoc26 GSSoC 2026 contribution type:bug GSSoC type bonus: bug fix type:testing GSSoC type bonus: tests (+10 pts) labels May 31, 2026
@github-actions
Copy link
Copy Markdown

GSSoC Label Checklist 🏷️

@Priyanshu-byte-coder — please apply the appropriate labels before merging:

Difficulty (pick one):

  • level:beginner — 20 pts
  • level:intermediate — 35 pts
  • level:advanced — 55 pts
  • level:critical — 80 pts

Quality (optional):

  • quality:clean — ×1.2 multiplier
  • quality:exceptional — ×1.5 multiplier

Validation (required to score):

  • gssoc:approved — counts for points
  • gssoc:invalid / gssoc:spam / gssoc:ai-slop — does not score

Type labels (type:*) are auto-detected from files and title. Review and adjust if needed.
Points formula: (difficulty × quality_multiplier) + type_bonus

@Priyanshu-byte-coder Priyanshu-byte-coder added level2 GSSoC Level 2 - Medium complexity (25 points) gssoc:approved GSSoC: PR approved for scoring labels May 31, 2026
@Priyanshu-byte-coder Priyanshu-byte-coder merged commit ac437b0 into Priyanshu-byte-coder:main May 31, 2026
4 of 5 checks passed
@github-actions
Copy link
Copy Markdown

🎉 Merged! Thanks for contributing to DevTrack.

If the project has been useful to you, a ⭐ star on the repo is the easiest way to support it — it helps DevTrack get discovered by more developers.

Keep an eye on open issues for your next contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gssoc:approved GSSoC: PR approved for scoring gssoc26 GSSoC 2026 contribution level2 GSSoC Level 2 - Medium complexity (25 points) type:bug GSSoC type bonus: bug fix type:testing GSSoC type bonus: tests (+10 pts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants