From 8cbab6823ecfb66cb128431e24146e9bbfb592b5 Mon Sep 17 00:00:00 2001 From: Ayobami Adegoke Date: Sun, 27 Sep 2026 18:28:16 +0100 Subject: [PATCH] fix(linkedin-search): reject detail URLs on other hosts instead of fetching the posting with that number (#500) normalizeId took the first 6+-digit path segment from ANY URL, so a Greenhouse or Lever apply link - the kind a posting's own page hands out and a user pastes back into detail - was reduced to that number and the handler fetched jobPosting/ from LinkedIn: whatever job carried that id came back with exit 0, or NOT_FOUND if none did, never an error about the input. Driving the real handler with a stubbed fetch, https://boards.greenhouse.io/acme/jobs/4567890 requested .../jobPosting/4567890. Every other portal CLI rejects an off-host detail URL with BAD_ID (the #447 shape); linkedin was the one still trusting the digits. Parse URLs for real: a linkedin.com host (apex or any subdomain, scheme optional) plus a /jobs/view/id path yields the id; other hosts, look-alike and userinfo hosts, and linkedin.com profile or search URLs exit 1 with the stderr-JSON BAD_ID contract before any request. Bare ids, URNs, and slash-free title slugs are unchanged. Four new normalizeId cases and a new detail-input.test.ts that drives runDetail and the CLI with fetch stubbed; five cases fail on the old pattern. --- .../cli/src/commands/detail.ts | 41 +++++++++-- .../cli/tests/detail-input.test.ts | 72 +++++++++++++++++++ .../linkedin-search/cli/tests/parsing.test.ts | 24 +++++++ CHANGELOG.md | 17 +++++ 4 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/linkedin-search/cli/tests/detail-input.test.ts diff --git a/.agents/skills/linkedin-search/cli/src/commands/detail.ts b/.agents/skills/linkedin-search/cli/src/commands/detail.ts index 9435543a..5c800791 100644 --- a/.agents/skills/linkedin-search/cli/src/commands/detail.ts +++ b/.agents/skills/linkedin-search/cli/src/commands/detail.ts @@ -5,15 +5,42 @@ export interface DetailOpts { format: "json" | "plain" } -/** Accept a raw job ID, a job-view URL, or a job URN. */ +const LINKEDIN_HOST = /(^|\.)linkedin\.com$/i + +/** + * Accept a raw job ID, a job URN, a LinkedIn job-view URL (any linkedin.com + * host, with or without scheme), or a bare title slug ending in the ID. + * + * A URL on any other host is rejected. The previous pattern took the first + * 6+-digit path segment from ANY URL, so a Greenhouse or Lever apply link - + * the kind a posting's own page hands out - fetched whatever LinkedIn + * posting happened to carry that number and printed it with exit 0. Host and + * path are read through real URL parsing, so look-alike hosts + * (linkedin.com.evil.io) and userinfo tricks (linkedin.com@evil.io) fall on + * the reject side too. + */ export function normalizeId(input: string): string | null { - const urn = input.match(/urn:li:jobPosting:(\d+)/) + const trimmed = input.trim() + const urn = trimmed.match(/urn:li:jobPosting:(\d+)/) if (urn) return urn[1] - const url = input.match(/-(\d{6,})(?:[\/?]|$)/) || input.match(/\/(\d{6,})(?:[\/?]|$)/) - if (url) return url[1] - const bare = input.match(/^\d{6,}$/) - if (bare) return input - return null + if (/^\d{6,}$/.test(trimmed)) return trimmed + + 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 (!LINKEDIN_HOST.test(parsed.hostname)) return null + const path = parsed.pathname.match(/\/jobs\/view\/(?:[^/]*-)?(\d{6,})\/?$/) + return path ? path[1] : null + } + + // A scheme- and slash-free slug such as "software-engineer-1234567890". + const slug = trimmed.match(/^[a-z0-9-]*-(\d{6,})$/i) + return slug ? slug[1] : null } export async function runDetail(opts: DetailOpts): Promise { diff --git a/.agents/skills/linkedin-search/cli/tests/detail-input.test.ts b/.agents/skills/linkedin-search/cli/tests/detail-input.test.ts new file mode 100644 index 00000000..d18a2210 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/detail-input.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { runDetail } from "../src/commands/detail"; +import { DETAIL_URL } from "../src/helpers"; +import { runCLI } from "./helpers"; + +// The real command path, not the parser in isolation: `detail` must never +// send a request for a URL that is not a LinkedIn job view. Before the host +// check, a Greenhouse apply link pasted from a posting was reduced to its +// 7-digit path segment and the handler fetched LinkedIn posting 4567890 - +// an unrelated job, printed with exit 0. fetch is stubbed throughout; no +// test here touches the network. + +const originalFetch = globalThis.fetch; +const originalStderrWrite = process.stderr.write; + +afterEach(() => { + globalThis.fetch = originalFetch; + process.stderr.write = originalStderrWrite; +}); + +function recordingFetch(): string[] { + const requested: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return new Response("", { status: 404 }); + }) as unknown as typeof fetch; + return requested; +} + +function captureStderr(): string[] { + const out: string[] = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + return out; +} + +describe("detail input on the real handler path", () => { + test("a foreign job-board URL is rejected with BAD_ID before any request", async () => { + const requested = recordingFetch(); + const stderr = captureStderr(); + + const rc = await runDetail({ id: "https://boards.greenhouse.io/acme/jobs/4567890", format: "json" }); + + expect(rc).toBe(1); + expect(requested).toEqual([]); + expect(JSON.parse(stderr.join("").trim())).toMatchObject({ code: "BAD_ID" }); + }); + + test("a LinkedIn job URL builds the fetch URL from the extracted id", async () => { + const requested = recordingFetch(); + captureStderr(); + + const rc = await runDetail({ + id: "https://dk.linkedin.com/jobs/view/data-scientist-9876543210/?trackingId=x", + format: "json", + }); + + expect(rc).toBe(1); // the stub answers 404 -> NOT_FOUND, after the request was made + expect(requested).toEqual([`${DETAIL_URL}/9876543210`]); + }); +}); + +describe("detail input through the CLI", () => { + test("exits 1 with the stderr-JSON BAD_ID contract for a foreign URL", async () => { + const result = await runCLI(["detail", "https://jobs.lever.co/acme/1234567"]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr)).toMatchObject({ code: "BAD_ID" }); + }); +}); diff --git a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts index 8cef41b3..40be2987 100644 --- a/.agents/skills/linkedin-search/cli/tests/parsing.test.ts +++ b/.agents/skills/linkedin-search/cli/tests/parsing.test.ts @@ -267,6 +267,30 @@ describe("normalizeId", () => { expect(normalizeId("https://dk.linkedin.com/jobs/view/data-scientist-9876543210/")).toBe("9876543210"); }); + test("accepts a scheme-less linkedin.com job URL and a bare title slug", () => { + expect(normalizeId("www.linkedin.com/jobs/view/1234567890")).toBe("1234567890"); + expect(normalizeId("software-engineer-1234567890")).toBe("1234567890"); + }); + + test("rejects a job URL on any other host instead of extracting its digits", () => { + // The old pattern took the first 6+-digit path segment from any URL, so an + // ATS apply link fetched an unrelated LinkedIn posting with that number. + expect(normalizeId("https://boards.greenhouse.io/acme/jobs/4567890")).toBeNull(); + expect(normalizeId("https://jobs.lever.co/acme/1234567")).toBeNull(); + expect(normalizeId("https://example.com/jobs/view/1234567890")).toBeNull(); + }); + + test("rejects look-alike and userinfo hosts", () => { + expect(normalizeId("https://linkedin.com.evil.io/jobs/view/1234567890")).toBeNull(); + expect(normalizeId("https://notlinkedin.com/jobs/view/1234567890")).toBeNull(); + expect(normalizeId("https://www.linkedin.com@evil.io/jobs/view/1234567890")).toBeNull(); + }); + + test("rejects a linkedin.com URL that is not a job view", () => { + expect(normalizeId("https://www.linkedin.com/in/someone-1234567890/")).toBeNull(); + expect(normalizeId("https://www.linkedin.com/jobs/search/?currentJobId=1234567890")).toBeNull(); + }); + test("returns null for non-job URLs and invalid strings", () => { expect(normalizeId("https://www.linkedin.com/feed/")).toBeNull(); expect(normalizeId("not-a-url")).toBeNull(); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5019bba2..b2d4be5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,23 @@ per-file diff commands. ### Fixed +- **`linkedin-search detail` no longer fetches an unrelated posting for a URL on another + host** - `normalizeId` took the first 6+-digit path segment from *any* URL, so a + Greenhouse or Lever apply link (the kind a posting's own page hands out, and what a user + pastes back into `detail`) was reduced to that number and the handler fetched + `jobPosting/` from LinkedIn: whatever job carried that id came back, printed with + exit 0, or `NOT_FOUND` if none did - never an error about the input. Demonstrated by driving + the real handler with a stubbed fetch: `https://boards.greenhouse.io/acme/jobs/4567890` + requested `.../jobPosting/4567890`. Every other portal CLI rejects an off-host detail URL + with `BAD_ID` (the #447 shape); linkedin was the one still trusting the digits. URLs are + now parsed for real: a `linkedin.com` host (apex or any subdomain, scheme optional) plus a + `/jobs/view/` path yields the id, and anything else - other hosts, look-alike and + userinfo hosts, a linkedin.com profile or search URL - exits 1 with the stderr-JSON + `BAD_ID` contract before any request. Bare ids, URNs, and slash-free title slugs are + unchanged. Pinned by four new `normalizeId` cases and a new `detail-input.test.ts` that + drives `runDetail` and the CLI with fetch stubbed; the off-host cases fail on the old + pattern. + - **`/rank` tracker matching preserves Unicode company and role names** (`tools/rank_state.py`, `tests/test_rank_state.py`) - ASCII-only normalization collapsed distinct non-Latin roles to the same empty value and dropped