From 447fec0302d50607203831b570787bed1f575b86 Mon Sep 17 00:00:00 2001 From: Ayobami Adegoke Date: Mon, 19 Oct 2026 18:20:06 +0100 Subject: [PATCH] fix(freehire-search): reject detail URLs on other hosts instead of asking the API for their last segment (#622) (#623) * fix(freehire-search): reject detail URLs on other hosts instead of asking the API for their last segment normalizeSlug took the segment after "/jobs/" from ANY string, so a Greenhouse apply link - which is what a freehire search result's own url field carries - was reduced to its last path segment and detail requested /api/v1/jobs/1, then reported NOT_FOUND for a URL that was never a freehire posting. Every other portal CLI rejects an off-host detail URL with BAD_ID (#447, #430, #499); freehire was the last one trusting the path. Parse URLs for real: freehire.me (apex or any subdomain, scheme optional) or the host of FREEHIRE_API_URL for a self-hosted instance, plus a /jobs/ path, yields the slug; other hosts, look-alike and userinfo hosts, and non-job freehire pages exit 1 with BAD_ID before any request. Bare slugs are unchanged. Five new normalizeSlug cases and a handler-level test that pastes the search fixture's own apply URL into detail with fetch stubbed; the off-host cases fail on master. * changelog: place the #623 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Mads Lorentzen Co-authored-by: Claude Opus 5.5 --- .agents/skills/freehire-search/SKILL.md | 2 +- .../skills/freehire-search/cli/src/helpers.ts | 40 +++++++++++++++++-- .../cli/tests/commands.test.ts | 17 ++++++++ .../freehire-search/cli/tests/parsing.test.ts | 40 +++++++++++++++++++ CHANGELOG.md | 17 ++++++++ 5 files changed, 112 insertions(+), 4 deletions(-) diff --git a/.agents/skills/freehire-search/SKILL.md b/.agents/skills/freehire-search/SKILL.md index 27dacb02..01221fa4 100644 --- a/.agents/skills/freehire-search/SKILL.md +++ b/.agents/skills/freehire-search/SKILL.md @@ -124,7 +124,7 @@ bun run .agents/skills/freehire-search/cli/src/cli.ts detail [--forma ``` `slug` is the `id` from a `search` result (e.g. `golang-zensar-2bxu6dxm`). You may -also pass a full `https://freehire.me/jobs/` URL. Returns the full (HTML-stripped) +also pass a full `https://freehire.me/jobs/` URL. A URL on any other host - including the employer apply link a search result's `url` field carries - is rejected with `BAD_ID` before any request. Returns the full (HTML-stripped) description, skills, region/country, and — when the posting is enriched — seniority, category, employment type, and salary. `salary` keeps its period when freehire records one (`INR 300000–300000/year`, `INR 12000/month`); compare pay only in one period. diff --git a/.agents/skills/freehire-search/cli/src/helpers.ts b/.agents/skills/freehire-search/cli/src/helpers.ts index a9520a61..290044af 100644 --- a/.agents/skills/freehire-search/cli/src/helpers.ts +++ b/.agents/skills/freehire-search/cli/src/helpers.ts @@ -246,12 +246,46 @@ export function cleanHtml(html: string | null | undefined): string | null { return text || null } -/** Extract a freehire public slug from a bare slug or a /jobs/ URL. */ +const FREEHIRE_HOST = /(^|\.)freehire\.me$/i + +/** True for freehire.me, any of its subdomains, or the configured self-hosted base URL's host. */ +function isFreehireHost(host: string): boolean { + if (FREEHIRE_HOST.test(host)) return true + try { + const self = new URL(baseUrl()).hostname.toLowerCase() + return host === self || host.endsWith(`.${self}`) + } catch { + return false + } +} + +/** + * Extract a freehire public slug from a bare slug or a freehire `/jobs/` URL. + * + * A URL on any other host is rejected. The previous pattern took the segment + * after `/jobs/` from ANY string, so a Greenhouse apply link - which is what a + * freehire search result's own `url` field carries - was reduced to its last + * path segment and `detail` asked the API for a slug that never existed, + * reporting "job not found" for a URL that was never a freehire posting. URLs + * are parsed for real, so look-alike hosts (freehire.me.evil.io) and userinfo + * tricks (freehire.me@evil.io) fall on the reject side; FREEHIRE_API_URL's host + * is accepted for a self-hosted instance. + */ export function normalizeSlug(input: string): string | null { const trimmed = input.trim() if (!trimmed) return null - const m = trimmed.match(/\/jobs\/([^/?#]+)/) - if (m) return m[1] + const hasScheme = /^https?:\/\//i.test(trimmed) + if (hasScheme || /^[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(trimmed)) { + let parsed: URL + try { + parsed = new URL(hasScheme ? trimmed : `https://${trimmed}`) + } catch { + return null + } + if (!isFreehireHost(parsed.hostname.toLowerCase())) return null + const m = parsed.pathname.match(/^\/jobs\/([a-z0-9][a-z0-9-]*)\/?$/i) + return m ? m[1] : null + } // A bare slug: lowercase alphanumerics and hyphens (no path/scheme). if (/^[a-z0-9][a-z0-9-]*$/i.test(trimmed)) return trimmed return null diff --git a/.agents/skills/freehire-search/cli/tests/commands.test.ts b/.agents/skills/freehire-search/cli/tests/commands.test.ts index f4324013..0108bdcd 100644 --- a/.agents/skills/freehire-search/cli/tests/commands.test.ts +++ b/.agents/skills/freehire-search/cli/tests/commands.test.ts @@ -235,3 +235,20 @@ describe("runDetail (mocked fetch)", () => { expect(JSON.parse(err).code).toBe("NOT_FOUND"); }); }); + +describe("detail input on the real handler path", () => { + test("a search result's own apply URL is rejected with BAD_ID before any request", async () => { + // Every freehire search result carries the employer's apply link in `url` + // (the fixture's is a Greenhouse board). Pasting it back into detail used + // to request /api/v1/jobs/1 and report NOT_FOUND for a URL that was never + // a freehire posting. + const mock = mockFetch(404, { error: "not found" }); + const err = captureStderr(); + + const code = await runDetail({ id: job().url, format: "json" }); + + expect(code).toBe(1); + expect(mock.url()).toBe(""); + expect(JSON.parse(err.get())).toMatchObject({ code: "BAD_ID" }); + }); +}); diff --git a/.agents/skills/freehire-search/cli/tests/parsing.test.ts b/.agents/skills/freehire-search/cli/tests/parsing.test.ts index f42a29fa..c1359fea 100644 --- a/.agents/skills/freehire-search/cli/tests/parsing.test.ts +++ b/.agents/skills/freehire-search/cli/tests/parsing.test.ts @@ -147,4 +147,44 @@ describe("normalizeSlug", () => { expect(normalizeSlug("not a slug!")).toBeNull(); expect(normalizeSlug("")).toBeNull(); }); + test("accepts subdomains, a scheme-less host, a trailing slash and a query string", () => { + expect(normalizeSlug("https://www.freehire.me/jobs/golang-zensar-2bxu6dxm/")).toBe("golang-zensar-2bxu6dxm"); + expect(normalizeSlug("freehire.me/jobs/golang-zensar-2bxu6dxm?utm_source=x")).toBe("golang-zensar-2bxu6dxm"); + }); + test("rejects a /jobs/ URL on another host instead of asking the API for its last segment", () => { + // The search fixture's own `url` is a Greenhouse apply link; pasting it + // back into detail used to request /api/v1/jobs/1 and report NOT_FOUND. + expect(normalizeSlug("https://boards.greenhouse.io/acme/jobs/1")).toBeNull(); + expect(normalizeSlug("https://jobs.lever.co/acme/jobs/abc")).toBeNull(); + }); + test("rejects look-alike and userinfo hosts", () => { + expect(normalizeSlug("https://freehire.me.evil.io/jobs/x")).toBeNull(); + expect(normalizeSlug("https://notfreehire.me/jobs/x")).toBeNull(); + expect(normalizeSlug("https://freehire.me@evil.io/jobs/x")).toBeNull(); + }); + test("rejects a freehire.me URL that is not a job page", () => { + expect(normalizeSlug("https://freehire.me/companies/acme")).toBeNull(); + expect(normalizeSlug("https://freehire.me/jobs/")).toBeNull(); + }); + test("requires the path to be exactly /jobs/", () => { + // The slug is the whole path after /jobs/: a nested path, a prefix before + // /jobs/, or a slug with characters outside the slug alphabet is not a + // freehire job page, even on the right host. + expect(normalizeSlug("https://freehire.me/jobs/my-slug/apply")).toBeNull(); + expect(normalizeSlug("https://freehire.me/en/jobs/my-slug")).toBeNull(); + expect(normalizeSlug("https://freehire.me/jobs/my_slug")).toBeNull(); + expect(normalizeSlug("https://freehire.me/jobs/my-slug#details")).toBe("my-slug"); + }); + + test("accepts the self-hosted base URL's host", () => { + const saved = process.env.FREEHIRE_API_URL; + process.env.FREEHIRE_API_URL = "http://localhost:8080"; + try { + expect(normalizeSlug("http://localhost:8080/jobs/my-slug")).toBe("my-slug"); + expect(normalizeSlug("https://boards.greenhouse.io/acme/jobs/1")).toBeNull(); + } finally { + if (saved === undefined) delete process.env.FREEHIRE_API_URL; + else process.env.FREEHIRE_API_URL = saved; + } + }); }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 546ed31c..9b5043fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,23 @@ per-file diff commands. ## [Unreleased] +### Fixed + +- **`freehire-search detail` no longer asks the API for a slug cut out of a URL on another + host** (#623, fixes #622, `.agents/skills/freehire-search/cli/src/helpers.ts`, `tests/parsing.test.ts`, + `tests/commands.test.ts`, `SKILL.md`) - `normalizeSlug` took the segment after `/jobs/` from + *any* string, so a Greenhouse apply link - which is what a freehire search result's own `url` + field carries - was reduced to its last path segment and `detail` requested + `/api/v1/jobs/1`, then reported `NOT_FOUND` for a URL that was never a freehire posting. + Every other portal CLI rejects an off-host detail URL with `BAD_ID` (#447, #430, #499); + freehire was the last one trusting the path. URLs are now parsed for real: `freehire.me` + (apex or any subdomain, scheme optional) or the host of `FREEHIRE_API_URL` for a self-hosted + instance, plus a `/jobs/` path and nothing more, yields the slug; other hosts, + look-alike and userinfo hosts, and non-job freehire pages exit 1 with `BAD_ID` before any + request. Bare slugs are unchanged. Pinned by six new `normalizeSlug` cases and a + handler-level test that pastes the search fixture's own apply URL into `detail` with fetch + stubbed; the off-host cases fail on master. + ## [1.7.3] - 2026-10-19 ### Added