From 1c39c51b1be000864899fd8a87e40cbb458036c9 Mon Sep 17 00:00:00 2001 From: zzr <103238445+wswsadadbaba123@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:47:11 +0800 Subject: [PATCH] fix(portal-clis): reject foreign detail URLs in Jobbank and Jobnet (#672) * fix(portal-clis): validate Jobbank and Jobnet detail URL hosts * fix(portal-clis): preserve scheme-less portal detail URLs Normalize host-like input with HTTPS before validating the portal host. Cover apex, www, mobile, uppercase and legacy routes while rejecting foreign or deceptive hosts and query-only posting paths. Thanks to MadsLorentzen for finding the compatibility regression in #672. * changelog: place the #672 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Mads Lorentzen Co-authored-by: Claude Opus 5.5 --- .../skills/jobbank-search/cli/src/helpers.ts | 13 ++++++- .../tests/detail-url-normalization.test.ts | 36 ++++++++++++++++++ .../cli/tests/reject-network.ts | 3 ++ .../skills/jobnet-search/cli/src/helpers.ts | 13 ++++++- .../tests/detail-url-normalization.test.ts | 38 +++++++++++++++++++ .../jobnet-search/cli/tests/reject-network.ts | 3 ++ CHANGELOG.md | 7 ++++ 7 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/jobbank-search/cli/tests/reject-network.ts create mode 100644 .agents/skills/jobnet-search/cli/tests/reject-network.ts diff --git a/.agents/skills/jobbank-search/cli/src/helpers.ts b/.agents/skills/jobbank-search/cli/src/helpers.ts index 249458eb..aafd0f39 100644 --- a/.agents/skills/jobbank-search/cli/src/helpers.ts +++ b/.agents/skills/jobbank-search/cli/src/helpers.ts @@ -162,7 +162,18 @@ export function parseRssDescription(desc: string): ParsedDescription { export function normalizeJobId(input: string): string | null { const trimmed = input.trim() if (/^\d+$/.test(trimmed)) return trimmed - const match = trimmed.match(/\/job\/(\d+)(?:\/|$|\?|#)/) + let url: URL + try { + const hostLike = /^[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(trimmed) + url = new URL(hostLike ? `https://${trimmed}` : trimmed) + } catch { + return null + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null + if (url.hostname !== "jobbank.dk" && !url.hostname.endsWith(".jobbank.dk")) return null + // A matching path on a different site is not a Jobbank posting. Read only + // the parsed pathname, so a job URL embedded in a query is not an ID either. + const match = url.pathname.match(/^\/job\/(\d+)(?:\/|$)/) return match ? match[1] : null } diff --git a/.agents/skills/jobbank-search/cli/tests/detail-url-normalization.test.ts b/.agents/skills/jobbank-search/cli/tests/detail-url-normalization.test.ts index 55e5a6bd..ac09dfde 100644 --- a/.agents/skills/jobbank-search/cli/tests/detail-url-normalization.test.ts +++ b/.agents/skills/jobbank-search/cli/tests/detail-url-normalization.test.ts @@ -3,6 +3,42 @@ import { normalizeJobId } from "../src/helpers.js" import { runCLI } from "./helpers.js" describe("jobbank-search normalizeJobId", () => { + test("accepts portal URLs without a scheme", () => { + for (const host of ["jobbank.dk", "www.jobbank.dk", "m.jobbank.dk", "JOBBANK.DK"]) { + expect(normalizeJobId(` ${host}/job/3117065/company/role?ref=search#apply `)).toBe("3117065") + } + }) + + test("rejects foreign hosts, non-web schemes and job paths only in a query", () => { + for (const value of [ + "https://example.com/job/304212/", + "https://jobbank.dk.example.com/job/304212/", + "ftp://jobbank.dk/job/304212/", + "https://jobbank.dk/?next=/job/304212/", + "example.com/job/304212/", + "jobbank.dk.evil.io/job/304212/", + "jobbank.dk@evil.io/job/304212/", + "jobbank.dk/?next=/job/304212/", + ]) expect(normalizeJobId(value)).toBeNull() + }) + + test("accepts the portal's www host and HTTP links", () => { + expect(normalizeJobId("http://www.jobbank.dk/job/304212/")).toBe("304212") + }) + + test("CLI rejects another site's matching job path before any fetch", async () => { + const proc = Bun.spawn([ + process.execPath, "--preload", `${import.meta.dir}/reject-network.ts`, + `${import.meta.dir}/../src/cli.ts`, "detail", "https://example.com/job/304212/", + ], { stdout: "pipe", stderr: "pipe" }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr).code).toBe("BAD_ID") + }) + test("accepts bare numeric ID", () => { expect(normalizeJobId("304212")).toBe("304212") expect(normalizeJobId(" 12345 ")).toBe("12345") diff --git a/.agents/skills/jobbank-search/cli/tests/reject-network.ts b/.agents/skills/jobbank-search/cli/tests/reject-network.ts new file mode 100644 index 00000000..86a30b09 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/reject-network.ts @@ -0,0 +1,3 @@ +globalThis.fetch = Object.assign(async () => { + throw new Error("unexpected network request for an invalid detail URL") +}, { preconnect() {} }) diff --git a/.agents/skills/jobnet-search/cli/src/helpers.ts b/.agents/skills/jobnet-search/cli/src/helpers.ts index f7147da6..f409dced 100644 --- a/.agents/skills/jobnet-search/cli/src/helpers.ts +++ b/.agents/skills/jobnet-search/cli/src/helpers.ts @@ -60,7 +60,18 @@ export function normalizeJobId(input: string): string | null { const trimmed = input.trim() if (!trimmed) return null if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return trimmed - const match = trimmed.match(/(?:\/find-job\/|\/JobAdDetails\/|\/Details\/)(?:detaljer\/)?([a-zA-Z0-9_-]+)(?:\/|$|\?|#)/i) + let url: URL + try { + const hostLike = /^[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(trimmed) + url = new URL(hostLike ? `https://${trimmed}` : trimmed) + } catch { + return null + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null + if (url.hostname !== "jobnet.dk" && !url.hostname.endsWith(".jobnet.dk")) return null + // Otherwise an unrelated site's matching path silently selects a Jobnet ad. + // Keep legacy routes, but never interpret a query or fragment as a job path. + const match = url.pathname.match(/(?:\/find-job\/|\/JobAdDetails\/|\/Details\/)(?:detaljer\/)?([a-zA-Z0-9_-]+)(?:\/|$)/i) if (match) return match[1] return null } diff --git a/.agents/skills/jobnet-search/cli/tests/detail-url-normalization.test.ts b/.agents/skills/jobnet-search/cli/tests/detail-url-normalization.test.ts index 4af27112..3de3134c 100644 --- a/.agents/skills/jobnet-search/cli/tests/detail-url-normalization.test.ts +++ b/.agents/skills/jobnet-search/cli/tests/detail-url-normalization.test.ts @@ -3,6 +3,44 @@ import { normalizeJobId } from "../src/helpers.js" import { runCLI } from "./helpers.js" describe("jobnet-search normalizeJobId", () => { + test("accepts portal URLs without a scheme", () => { + const id = "12345678-1234-1234-1234-123456789abc" + for (const host of ["jobnet.dk", "www.jobnet.dk", "m.jobnet.dk", "JOBNET.DK"]) { + expect(normalizeJobId(` ${host}/find-job/${id}?ref=search#apply `)).toBe(id) + } + expect(normalizeJobId(`job.jobnet.dk/CV/FindWork/Details/${id}`)).toBe(id) + }) + + test("rejects foreign hosts, non-web schemes and job paths only in a query", () => { + for (const value of [ + "https://example.com/find-job/6123456/", + "https://jobnet.dk.example.com/find-job/6123456/", + "ftp://jobnet.dk/find-job/6123456/", + "https://jobnet.dk/?next=/find-job/6123456/", + "example.com/find-job/6123456/", + "jobnet.dk.evil.io/find-job/6123456/", + "jobnet.dk@evil.io/find-job/6123456/", + "jobnet.dk/?next=/find-job/6123456/", + ]) expect(normalizeJobId(value)).toBeNull() + }) + + test("accepts the portal's www host and HTTP links", () => { + expect(normalizeJobId("http://www.jobnet.dk/find-job/6123456/")).toBe("6123456") + }) + + test("CLI rejects another site's matching job path before any fetch", async () => { + const proc = Bun.spawn([ + process.execPath, "--preload", `${import.meta.dir}/reject-network.ts`, + `${import.meta.dir}/../src/cli.ts`, "detail", "https://example.com/find-job/6123456/", + ], { stdout: "pipe", stderr: "pipe" }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, + ]) + expect(code).toBe(1) + expect(stdout).toBe("") + expect(JSON.parse(stderr).code).toBe("BAD_ID") + }) + test("accepts bare numeric ID", () => { expect(normalizeJobId("6123456")).toBe("6123456") expect(normalizeJobId(" 6123456 ")).toBe("6123456") diff --git a/.agents/skills/jobnet-search/cli/tests/reject-network.ts b/.agents/skills/jobnet-search/cli/tests/reject-network.ts new file mode 100644 index 00000000..86a30b09 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/reject-network.ts @@ -0,0 +1,3 @@ +globalThis.fetch = Object.assign(async () => { + throw new Error("unexpected network request for an invalid detail URL") +}, { preconnect() {} }) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648099c5..26a8957c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -271,6 +271,13 @@ per-file diff commands. `x=ok; test "$x" = ok`, Git for Windows' own Bash is tried when the launcher fails the probe, and the replay checks skip only when no usable shell is found. +- **Jobbank and Jobnet `detail` reject posting URLs on other hosts** (#672, + `.agents/skills/jobbank-search/cli/src/helpers.ts`, `.agents/skills/jobnet-search/cli/src/helpers.ts`) - + previously, a matching `/job/` or `/find-job/` path on any website was silently interpreted as + a posting on the selected portal. Both CLIs now require an HTTP(S) URL on their own domain and extract + the ID from its pathname only. Bare IDs, portal links without a scheme, portal subdomains, and Jobnet's + legacy posting routes remain valid. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a