Merge pull request #123 from BowlesCR/fix/sponsors-graphql-injection-drop

Remove username as public input from /sponsors/github (extends #121)
This commit is contained in:
stopflock
2026-07-08 18:41:29 -05:00
committed by GitHub
4 changed files with 75 additions and 12 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ A Fastify-based API service for DeFlock handling non-OSM related backend logic.
## Endpoints
- `/geocode?query=...` — Geocode a location
- `/sponsors/github?username=...` — Get GitHub sponsors
- `/sponsors/github` — Get GitHub sponsors
- `/healthcheck` — Health check
## Development
+1 -8
View File
@@ -205,21 +205,14 @@ const start = async () => {
server.get('/sponsors/github', {
schema: {
querystring: {
type: 'object',
properties: {
username: { type: 'string', default: 'frillweeman' },
},
},
response: {
200: SponsorsResponseSchema,
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { username } = request.query as { username?: string };
reply.header('Cache-Control', 'public, max-age=60, s-maxage=600');
const result = await githubClient.getSponsors(username || 'frillweeman');
const result = await githubClient.getSponsors();
return result;
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect, afterEach, mock } from 'bun:test';
import { GithubClient, buildSponsorsQuery } from './GithubClient';
describe('buildSponsorsQuery', () => {
it('parameterizes the username via a $username GraphQL variable instead of interpolating it', () => {
const { query, variables } = buildSponsorsQuery();
expect(query).toContain('query($username: String!)');
expect(query).toContain('user(login: $username)');
expect(variables).toEqual({ username: 'frillweeman' });
});
it('requests sponsorships and the expected sponsor fields', () => {
const { query } = buildSponsorsQuery();
expect(query).toContain('sponsorshipsAsMaintainer(first: 100)');
for (const field of ['login', 'name', 'avatarUrl', 'url']) {
expect(query).toContain(field);
}
});
});
describe('GithubClient.getSponsors', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
it('sends exactly the query and variables built by buildSponsorsQuery', async () => {
let capturedBody: any;
global.fetch = mock(async (_url: string, init: RequestInit) => {
capturedBody = JSON.parse(init.body as string);
return new Response(JSON.stringify({ data: { user: { sponsorshipsAsMaintainer: { nodes: [] } } } }));
}) as unknown as typeof fetch;
const client = new GithubClient();
await client.getSponsors();
expect(capturedBody).toEqual(buildSponsorsQuery());
});
it('returns the sponsor nodes from the response', async () => {
const nodes = [{ sponsor: { login: 'someone', name: 'Someone', avatarUrl: 'https://example.com/a.png', url: 'https://github.com/someone' } }];
global.fetch = mock(async () =>
new Response(JSON.stringify({ data: { user: { sponsorshipsAsMaintainer: { nodes } } } }))
) as unknown as typeof fetch;
const client = new GithubClient();
const result = await client.getSponsors();
expect(result).toEqual(nodes);
});
it('throws when the GitHub API responds with a non-OK status', async () => {
global.fetch = mock(async () => new Response('', { status: 500 })) as unknown as typeof fetch;
const client = new GithubClient();
await expect(client.getSponsors()).rejects.toThrow('Failed to get sponsors: 500');
});
});
+13 -3
View File
@@ -3,6 +3,7 @@ import { Type, Static } from '@sinclair/typebox';
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
const graphQLEndpoint = 'https://api.github.com/graphql';
const MAINTAINER_USERNAME = 'frillweeman';
export const SponsorSchema = Type.Object({
sponsor: Type.Object({
@@ -17,10 +18,19 @@ export type Sponsor = Static<typeof SponsorSchema>;
export const SponsorsResponseSchema = Type.Array(SponsorSchema);
// The maintainer's username is passed as a GraphQL variable rather than
// interpolated into the query string. It's a hardcoded constant today, but
// keeping this indirection means a future caller-supplied value can never
// break out of the query text the way the old string-interpolated version did.
export const buildSponsorsQuery = (): { query: string; variables: { username: string } } => ({
query: `query($username: String!) { user(login: $username) { sponsorshipsAsMaintainer(first: 100) { nodes { sponsor { login name avatarUrl url } } } } }`,
variables: { username: MAINTAINER_USERNAME },
});
export class GithubClient {
async getSponsors(username: string): Promise<Sponsor[]> {
const query = `query { user(login: "${username}") { sponsorshipsAsMaintainer(first: 100) { nodes { sponsor { login name avatarUrl url } } } } }`;
const body = JSON.stringify({ query, variables: '' });
async getSponsors(): Promise<Sponsor[]> {
const { query, variables } = buildSponsorsQuery();
const body = JSON.stringify({ query, variables });
const response = await fetch(graphQLEndpoint, {
method: 'POST',
headers: {