mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
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:
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() {} })
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user