diff --git a/README.md b/README.md index 47c8684..dd3d5b5 100644 --- a/README.md +++ b/README.md @@ -15,23 +15,23 @@ Tiny API that clones a Git repo and counts its non-empty lines of code. ## Endpoints -- `GET /loc.txt?repo=&ssh_key=&ref=` +- `GET /loc.txt?repo=&ssh_key=&ref=&api_key=` - Returns the line count as plain text. -- `GET /loc?repo=&ssh_key=&ref=` +- `GET /loc?repo=&ssh_key=&ref=&api_key=` - Returns JSON metadata, including a language breakdown by files and non-empty lines. - `GET /health` - Health plus queue/cache stats. -You can also pass `query=` as a compatibility alias for `repo`. -This is considered unsafe and discouraged because the name is too generic and easier to misuse or collide with upstream tooling. -Prefer `repo` unless you explicitly need legacy compatibility. - ## Auth No auth by default. If `API_KEY` is set, send it as either: - `x-api-key: ...` - `Authorization: Bearer ...` +- `api_key=...` query param + +The `api_key` query param is unsafe and discouraged because it can leak through logs, browser history, analytics, caches, and referrers. +Prefer `x-api-key` or `Authorization` whenever possible. ## SSH keys diff --git a/src/app.ts b/src/app.ts index f78ab33..94c5bb8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -42,8 +42,9 @@ export function createApp(config: RuntimeConfig, deps: AppDependencies) { const headerKey = req.header("x-api-key"); const bearer = req.header("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const queryKey = readOptionalString(req.query.api_key); - if (headerKey === config.apiKey || bearer === config.apiKey) { + if (headerKey === config.apiKey || bearer === config.apiKey || queryKey === config.apiKey) { next(); return; } @@ -71,11 +72,7 @@ export function createApp(config: RuntimeConfig, deps: AppDependencies) { } function readCountRequest(req: Request): CountRequest { - const repo = typeof req.query.repo === "string" - ? req.query.repo.trim() - : typeof req.query.query === "string" - ? req.query.query.trim() - : ""; + const repo = typeof req.query.repo === "string" ? req.query.repo.trim() : ""; if (!repo) { throw new HttpError(400, "Missing repo query parameter"); } diff --git a/tests/app.test.js b/tests/app.test.js index 06649e9..b56c771 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -45,30 +45,6 @@ test("GET /loc returns count metadata", async () => { assert.equal(response.body.languages[0].language, "TypeScript"); }); -test("GET /loc accepts query as a repo alias", async () => { - const app = createApp(baseConfig, { - getHealth: () => ({ cacheEntries: 0, inFlight: 0, maxConcurrentScans: 4, activeScans: 0, queuedScans: 0 }), - getPublicKey: async () => "ssh-ed25519 AAAA", - getDefaultKeyName: () => "loc_via_git_ed25519", - count: async (requestInput) => ({ - repo: requestInput.repo, - ref: requestInput.ref, - sshKey: requestInput.sshKey, - cached: false, - lineCount: 2, - fileCount: 1, - languages: [{ language: "Plain Text", files: 1, lines: 2 }], - scannedAt: "2026-01-01T00:00:00.000Z", - durationMs: 10 - }) - }); - - const response = await request(app).get("/loc?query=https://example.com/query.git"); - - assert.equal(response.status, 200); - assert.equal(response.body.repo, "https://example.com/query.git"); -}); - test("GET /ssh/public-key returns text without auth", async () => { const app = createApp({ ...baseConfig, apiKey: "secret" }, { getHealth: () => ({ cacheEntries: 0, inFlight: 0, maxConcurrentScans: 4, activeScans: 0, queuedScans: 0 }), @@ -99,3 +75,27 @@ test("GET /loc enforces api key when configured", async () => { assert.equal(response.status, 401); }); + +test("GET /loc accepts api_key query param as an unsafe fallback", async () => { + const app = createApp({ ...baseConfig, apiKey: "secret" }, { + getHealth: () => ({ cacheEntries: 0, inFlight: 0, maxConcurrentScans: 4, activeScans: 0, queuedScans: 0 }), + getPublicKey: async () => "ssh-ed25519 AAAA", + getDefaultKeyName: () => "loc_via_git_ed25519", + count: async (requestInput) => ({ + repo: requestInput.repo, + ref: requestInput.ref, + sshKey: requestInput.sshKey, + cached: false, + lineCount: 5, + fileCount: 1, + languages: [{ language: "Plain Text", files: 1, lines: 5 }], + scannedAt: "2026-01-01T00:00:00.000Z", + durationMs: 10 + }) + }); + + const response = await request(app).get("/loc?repo=https://example.com/repo.git&api_key=secret"); + + assert.equal(response.status, 200); + assert.equal(response.body.lineCount, 5); +});