diff --git a/api/server.ts b/api/server.ts index 34ad88c..6ece654 100644 --- a/api/server.ts +++ b/api/server.ts @@ -208,7 +208,8 @@ const start = async () => { querystring: { type: 'object', properties: { - username: { type: 'string', default: 'frillweeman' }, + // GitHub usernames are 1-39 chars of letters, digits, and hyphens. + username: { type: 'string', pattern: '^[A-Za-z0-9-]{1,39}$', default: 'frillweeman' }, }, }, response: { diff --git a/api/services/GithubClient.test.ts b/api/services/GithubClient.test.ts new file mode 100644 index 0000000..b375557 --- /dev/null +++ b/api/services/GithubClient.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'bun:test'; +import { buildSponsorsQuery } from './GithubClient'; + +describe('buildSponsorsQuery', () => { + it('parameterizes the username instead of interpolating it', () => { + const { query, variables } = buildSponsorsQuery('frillweeman'); + expect(query).toContain('query($login: String!)'); + expect(query).toContain('user(login: $login)'); + expect(variables).toEqual({ login: 'frillweeman' }); + }); + + it('never places the raw username inside the query string', () => { + const { query } = buildSponsorsQuery('octocat'); + // The query is fixed and only references the $login variable — the caller's + // value must not appear in the query text itself. + expect(query).not.toContain('octocat'); + }); + + it('keeps GraphQL-breaking input confined to the variables (injection guard)', () => { + // A username containing a quote used to break out of the interpolated + // string literal. It must now travel only in `variables.login`. + const hostile = '") { viewer { login } } #'; + const { query, variables } = buildSponsorsQuery(hostile); + expect(variables.login).toBe(hostile); + expect(query).not.toContain(hostile); + expect(query).not.toContain('viewer'); + }); + + it('requests sponsorships and the expected sponsor fields', () => { + const { query } = buildSponsorsQuery('frillweeman'); + expect(query).toContain('sponsorshipsAsMaintainer(first: 100)'); + for (const field of ['login', 'name', 'avatarUrl', 'url']) { + expect(query).toContain(field); + } + }); +}); diff --git a/api/services/GithubClient.ts b/api/services/GithubClient.ts index c37c571..cb5241e 100644 --- a/api/services/GithubClient.ts +++ b/api/services/GithubClient.ts @@ -17,10 +17,18 @@ export type Sponsor = Static; 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 { - const query = `query { user(login: "${username}") { sponsorshipsAsMaintainer(first: 100) { nodes { sponsor { login name avatarUrl url } } } } }`; - const body = JSON.stringify({ query, variables: '' }); + const { query, variables } = buildSponsorsQuery(username); + const body = JSON.stringify({ query, variables }); const response = await fetch(graphQLEndpoint, { method: 'POST', headers: {