Files
deflock/api/services/GithubClient.ts
T
Jeremy Knows 3eceedb9c6 fix GraphQL query injection in /sponsors/github
The username query param was interpolated directly into the GitHub GraphQL
query string (user(login: "${username}")) sent with the server token, and
the route only validated it as a string, so a value containing a quote could
break out of the string literal and alter the query document. Impact depends
on the token's scope; at minimum it allows tampering with the query structure.

Two changes:
- Pass the username as a $login GraphQL variable so it can never alter the
  query document (the actual fix).
- Validate username against GitHub's username format at the route as
  defense-in-depth, rejecting malformed input before it reaches the API.

Adds a regression test for the query builder (bun test). No change in
returned data for valid usernames.
2026-07-07 22:27:40 -04:00

48 lines
1.6 KiB
TypeScript

import { Type, Static } from '@sinclair/typebox';
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
const graphQLEndpoint = 'https://api.github.com/graphql';
export const SponsorSchema = Type.Object({
sponsor: Type.Object({
avatarUrl: Type.String(),
login: Type.String(),
name: Type.Union([Type.String(), Type.Null()]),
url: Type.String(),
}),
});
export type Sponsor = Static<typeof SponsorSchema>;
export const SponsorsResponseSchema = Type.Array(SponsorSchema);
// Pass the username as a GraphQL variable rather than interpolating it into the
// query string, so a value containing quotes or other GraphQL syntax cannot
// break out of the string literal and alter the query.
export const buildSponsorsQuery = (username: string): { query: string; variables: { login: string } } => ({
query: `query($login: String!) { user(login: $login) { sponsorshipsAsMaintainer(first: 100) { nodes { sponsor { login name avatarUrl url } } } } }`,
variables: { login: username },
});
export class GithubClient {
async getSponsors(username: string): Promise<Sponsor[]> {
const { query, variables } = buildSponsorsQuery(username);
const body = JSON.stringify({ query, variables });
const response = await fetch(graphQLEndpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'User-Agent': 'Shotgun',
'Content-Type': 'application/json',
},
body,
});
if (!response.ok) {
throw new Error(`Failed to get sponsors: ${response.status}`);
}
const json = await response.json();
return json?.data?.user?.sponsorshipsAsMaintainer?.nodes || [];
}
}