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/<slug> 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 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-19 19:20:06 +02:00
1 parent 84808eebc1
commit 447fec0302
5 files changed
+112 -4

No files matched your search

+1 -1
View File
@@ -124,7 +124,7 @@ bun run .agents/skills/freehire-search/cli/src/cli.ts detail <slug|url> [--forma
```
`slug` is the `id` from a `search` result (e.g. `golang-zensar-2bxu6dxm`). You may
also pass a full `https://freehire.me/jobs/<slug>` URL. Returns the full (HTML-stripped)
also pass a full `https://freehire.me/jobs/<slug>` 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.
@@ -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/<slug> 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/<slug>` 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
@@ -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" });
});
});
@@ -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/<slug>", () => {
// 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;
}
});
});