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 <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-20 17:47:11 +02:00
1 parent 215dfb5af9
commit 1c39c51b1b
7 files changed
+111 -2

No files matched your search

@@ -162,7 +162,18 @@ export function parseRssDescription(desc: string): ParsedDescription {
export function normalizeJobId(input: string): string | null { export function normalizeJobId(input: string): string | null {
const trimmed = input.trim() const trimmed = input.trim()
if (/^\d+$/.test(trimmed)) return trimmed 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 return match ? match[1] : null
} }
@@ -3,6 +3,42 @@ import { normalizeJobId } from "../src/helpers.js"
import { runCLI } from "./helpers.js" import { runCLI } from "./helpers.js"
describe("jobbank-search normalizeJobId", () => { 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", () => { test("accepts bare numeric ID", () => {
expect(normalizeJobId("304212")).toBe("304212") expect(normalizeJobId("304212")).toBe("304212")
expect(normalizeJobId(" 12345 ")).toBe("12345") expect(normalizeJobId(" 12345 ")).toBe("12345")
@@ -0,0 +1,3 @@
globalThis.fetch = Object.assign(async () => {
throw new Error("unexpected network request for an invalid detail URL")
}, { preconnect() {} })
@@ -60,7 +60,18 @@ export function normalizeJobId(input: string): string | null {
const trimmed = input.trim() const trimmed = input.trim()
if (!trimmed) return null if (!trimmed) return null
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return trimmed 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] if (match) return match[1]
return null return null
} }
@@ -3,6 +3,44 @@ import { normalizeJobId } from "../src/helpers.js"
import { runCLI } from "./helpers.js" import { runCLI } from "./helpers.js"
describe("jobnet-search normalizeJobId", () => { 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", () => { test("accepts bare numeric ID", () => {
expect(normalizeJobId("6123456")).toBe("6123456") expect(normalizeJobId("6123456")).toBe("6123456")
expect(normalizeJobId(" 6123456 ")).toBe("6123456") expect(normalizeJobId(" 6123456 ")).toBe("6123456")
@@ -0,0 +1,3 @@
globalThis.fetch = Object.assign(async () => {
throw new Error("unexpected network request for an invalid detail URL")
}, { preconnect() {} })
+7
View File
@@ -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 `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. 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/<id>` or `/find-job/<id>` 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 ### Security
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a