crawlforge-extractors 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/body.js +11 -2
- package/src/connectors/ats.js +11 -10
- package/src/embeddedState.js +8 -2
- package/src/jsonPath.js +1 -1
- package/src/templates.js +12 -9
- package/src/urls.js +41 -0
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ const template = registry.get('shopify-product');
|
|
|
40
40
|
const url = 'https://shop.example.com/products/some-handle';
|
|
41
41
|
const fetchUrl = template.resolveUrl ? template.resolveUrl(url) : url;
|
|
42
42
|
|
|
43
|
+
// Apply your SSRF policy to fetchUrl (the RETURNED url) and to any redirect the
|
|
44
|
+
// fetch follows — not just to `url`. resolveUrl/listUrl may rewrite the target.
|
|
43
45
|
const body = await (await fetch(fetchUrl)).text();
|
|
44
46
|
const result = await registry.run('shopify-product', body, url, fetchUrl);
|
|
45
47
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.3",
|
|
4
4
|
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, and embedded-state extraction. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/src/body.js
CHANGED
|
@@ -70,9 +70,18 @@ export async function readBody(response, options = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// Only the byte-count guard needs a stream. Responses that are already
|
|
73
|
-
// buffered (and test doubles)
|
|
73
|
+
// buffered (and test doubles) have no reader to meter, but a server can omit
|
|
74
|
+
// or lie about Content-Length, so enforce the cap on the read result too.
|
|
74
75
|
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
75
|
-
|
|
76
|
+
const text = await response.text();
|
|
77
|
+
const size = Buffer.byteLength(text, 'utf8');
|
|
78
|
+
if (size > maxBytes) {
|
|
79
|
+
throw new BodyTooLargeError(
|
|
80
|
+
`Response body too large: ${size} bytes exceeds limit of ${maxBytes} bytes`,
|
|
81
|
+
{ limit: maxBytes, size }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return text;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
const reader = response.body.getReader();
|
package/src/connectors/ats.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { load } from 'cheerio';
|
|
23
|
+
import { safeHref } from '../urls.js';
|
|
23
24
|
|
|
24
25
|
// ── The common job shape ─────────────────────────────────────────────────────
|
|
25
26
|
|
|
@@ -269,7 +270,7 @@ export const ATS_TEMPLATES = [
|
|
|
269
270
|
const items = payload.jobs.map(j => job({
|
|
270
271
|
id: id(j.id),
|
|
271
272
|
title: str(j.title),
|
|
272
|
-
url: str(j.absolute_url),
|
|
273
|
+
url: safeHref(str(j.absolute_url)),
|
|
273
274
|
location: str(j.location?.name),
|
|
274
275
|
// departments and offices ship only with content=true, so a summary
|
|
275
276
|
// record reports null here rather than a department guessed from the
|
|
@@ -359,7 +360,7 @@ export const ATS_TEMPLATES = [
|
|
|
359
360
|
id: id(p.id),
|
|
360
361
|
// Lever calls the job title "text".
|
|
361
362
|
title: str(p.text),
|
|
362
|
-
url: str(p.hostedUrl),
|
|
363
|
+
url: safeHref(str(p.hostedUrl)),
|
|
363
364
|
location: str(categories.location),
|
|
364
365
|
department: str(categories.department),
|
|
365
366
|
team: str(categories.team),
|
|
@@ -377,7 +378,7 @@ export const ATS_TEMPLATES = [
|
|
|
377
378
|
// cities lists all three here.
|
|
378
379
|
all_locations: (categories.allLocations || []).map(str).filter(Boolean),
|
|
379
380
|
salary_range: p.salaryRange || null,
|
|
380
|
-
apply_url: str(p.applyUrl)
|
|
381
|
+
apply_url: safeHref(str(p.applyUrl))
|
|
381
382
|
}
|
|
382
383
|
});
|
|
383
384
|
});
|
|
@@ -432,7 +433,7 @@ export const ATS_TEMPLATES = [
|
|
|
432
433
|
const items = payload.jobs.map(j => job({
|
|
433
434
|
id: id(j.id),
|
|
434
435
|
title: str(j.title),
|
|
435
|
-
url: str(j.jobUrl),
|
|
436
|
+
url: safeHref(str(j.jobUrl)),
|
|
436
437
|
location: str(j.location),
|
|
437
438
|
department: str(j.department),
|
|
438
439
|
team: str(j.team),
|
|
@@ -452,7 +453,7 @@ export const ATS_TEMPLATES = [
|
|
|
452
453
|
// Names only. The full entries carry a postal address per country,
|
|
453
454
|
// which is an office directory, not part of a job listing.
|
|
454
455
|
secondary_locations: (j.secondaryLocations || []).map(l => str(l.location)).filter(Boolean),
|
|
455
|
-
apply_url: str(j.applyUrl)
|
|
456
|
+
apply_url: safeHref(str(j.applyUrl))
|
|
456
457
|
}
|
|
457
458
|
}));
|
|
458
459
|
|
|
@@ -523,7 +524,7 @@ export const ATS_TEMPLATES = [
|
|
|
523
524
|
// separate numeric id in this payload.
|
|
524
525
|
id: id(j.shortcode),
|
|
525
526
|
title: str(j.title),
|
|
526
|
-
url: str(j.url),
|
|
527
|
+
url: safeHref(str(j.url)),
|
|
527
528
|
location: joinLocation(j.city, j.state, j.country),
|
|
528
529
|
department: str(j.department),
|
|
529
530
|
// Workable has no team level.
|
|
@@ -540,7 +541,7 @@ export const ATS_TEMPLATES = [
|
|
|
540
541
|
shortcode: str(j.shortcode),
|
|
541
542
|
code: str(j.code),
|
|
542
543
|
function: str(j.function),
|
|
543
|
-
apply_url: str(j.application_url)
|
|
544
|
+
apply_url: safeHref(str(j.application_url))
|
|
544
545
|
}
|
|
545
546
|
}));
|
|
546
547
|
|
|
@@ -602,7 +603,7 @@ export const ATS_TEMPLATES = [
|
|
|
602
603
|
const items = payload.offers.map(o => job({
|
|
603
604
|
id: id(o.id),
|
|
604
605
|
title: str(o.title),
|
|
605
|
-
url: str(o.careers_url),
|
|
606
|
+
url: safeHref(str(o.careers_url)),
|
|
606
607
|
location: str(o.location),
|
|
607
608
|
department: str(o.department),
|
|
608
609
|
team: null,
|
|
@@ -623,7 +624,7 @@ export const ATS_TEMPLATES = [
|
|
|
623
624
|
slug: str(o.slug),
|
|
624
625
|
salary: o.salary || null,
|
|
625
626
|
tags: (o.tags || []).map(str).filter(Boolean),
|
|
626
|
-
apply_url: str(o.careers_apply_url)
|
|
627
|
+
apply_url: safeHref(str(o.careers_apply_url))
|
|
627
628
|
}
|
|
628
629
|
}));
|
|
629
630
|
|
|
@@ -715,7 +716,7 @@ export const ATS_TEMPLATES = [
|
|
|
715
716
|
// is the one their support pages call the "Job ID".
|
|
716
717
|
id: id(field('guid')),
|
|
717
718
|
title: field('title'),
|
|
718
|
-
url: field('link'),
|
|
719
|
+
url: safeHref(field('link')),
|
|
719
720
|
location: locations.length ? locations.join(', ') : null,
|
|
720
721
|
department: field('tt\\:department'),
|
|
721
722
|
team: null,
|
package/src/embeddedState.js
CHANGED
|
@@ -147,8 +147,14 @@ function parseFlightRows(stream) {
|
|
|
147
147
|
const textRow = payload.match(/^T([0-9a-f]+),/i);
|
|
148
148
|
if (textRow) {
|
|
149
149
|
const blobStart = payloadStart + textRow[0].length;
|
|
150
|
-
const
|
|
151
|
-
|
|
150
|
+
const byteLen = parseInt(textRow[1], 16);
|
|
151
|
+
// Decode only up to byteLen bytes. Slicing the whole remaining stream on
|
|
152
|
+
// every text row makes this O(N) per row -> O(N^2) for an
|
|
153
|
+
// attacker-controlled stream of many small text rows. byteLen bytes span
|
|
154
|
+
// at most byteLen characters, so bounding the slice to that many chars
|
|
155
|
+
// keeps the work linear and yields the identical decoded blob.
|
|
156
|
+
const text = Buffer.from(stream.slice(blobStart, blobStart + byteLen), 'utf8')
|
|
157
|
+
.subarray(0, byteLen)
|
|
152
158
|
.toString('utf8');
|
|
153
159
|
rows[id] = text;
|
|
154
160
|
cursor = blobStart + text.length;
|
package/src/jsonPath.js
CHANGED
|
@@ -66,7 +66,7 @@ export function selectJsonPath(root, path) {
|
|
|
66
66
|
for (const segment of segments) {
|
|
67
67
|
const container = current !== null && typeof current === 'object';
|
|
68
68
|
const key = Array.isArray(current) ? Number(segment) : segment;
|
|
69
|
-
if (!container || !(key
|
|
69
|
+
if (!container || !Object.hasOwn(current, key)) {
|
|
70
70
|
const at = walked.length === 0 ? 'the result' : `"${walked.join('.')}"`;
|
|
71
71
|
throw new Error(
|
|
72
72
|
`Path "${path}" not found: ${at} has no "${segment}" (${describeOptions(current)})`
|
package/src/templates.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Templates do NOT make network calls. The caller fetches the page and passes
|
|
16
16
|
* the body in; that keeps SSRF policy, timeouts and billing with the surface
|
|
17
|
-
* that owns them.
|
|
17
|
+
* that owns them. resolveUrl/listUrl rewrite or build the target, so the caller
|
|
18
|
+
* must apply its SSRF policy to the URL they RETURN (and to every redirect the
|
|
19
|
+
* fetch follows), not just to the URL the caller started with.
|
|
18
20
|
*
|
|
19
21
|
* Two optional hooks let a template read a machine-readable endpoint instead of
|
|
20
22
|
* scraping the rendered page, without taking the fetch into its own hands:
|
|
@@ -45,6 +47,7 @@ import { load } from 'cheerio';
|
|
|
45
47
|
// have made one file the whole package.
|
|
46
48
|
import { ATS_TEMPLATES } from './connectors/ats.js';
|
|
47
49
|
import { GOV_TEMPLATES } from './connectors/gov.js';
|
|
50
|
+
import { safeHref } from './urls.js';
|
|
48
51
|
|
|
49
52
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
50
53
|
|
|
@@ -608,7 +611,7 @@ export const TEMPLATES = [
|
|
|
608
611
|
last_updated: attr($, 'relative-time', 'datetime'),
|
|
609
612
|
// When the sidebar payload is present it is authoritative: an empty
|
|
610
613
|
// website means "no homepage", not "go scrape some external link".
|
|
611
|
-
homepage: about ? about.website
|
|
614
|
+
homepage: safeHref(about ? about.website : attr($, 'a[href][rel="noopener noreferrer"]', 'href')),
|
|
612
615
|
open_issues: githubCounter($, '#issues-repo-tab-count') || text($, '.Counter[aria-label*="issue"]')
|
|
613
616
|
};
|
|
614
617
|
}
|
|
@@ -623,7 +626,7 @@ export const TEMPLATES = [
|
|
|
623
626
|
return {
|
|
624
627
|
title: attr($, 'meta[name="title"]', 'content') || attr($, 'meta[property="og:title"]', 'content'),
|
|
625
628
|
channel: attr($, 'link[itemprop="name"]', 'content') || text($, '#channel-name'),
|
|
626
|
-
channel_url: attr($, 'span[itemprop="author"] link[itemprop="url"]', 'href'),
|
|
629
|
+
channel_url: safeHref(attr($, 'span[itemprop="author"] link[itemprop="url"]', 'href')),
|
|
627
630
|
views: youtubeInteractionCount($, 'WatchAction'),
|
|
628
631
|
likes: youtubeInteractionCount($, 'LikeAction'),
|
|
629
632
|
published: attr($, 'meta[itemprop="uploadDate"]', 'content') || attr($, 'meta[itemprop="datePublished"]', 'content'),
|
|
@@ -687,7 +690,7 @@ export const TEMPLATES = [
|
|
|
687
690
|
body: post.selftext || null,
|
|
688
691
|
// A link post carries its external URL here; a self post carries its
|
|
689
692
|
// own permalink, which `url` already reports.
|
|
690
|
-
link_url: post.is_self ? null : (post.url
|
|
693
|
+
link_url: post.is_self ? null : safeHref(post.url),
|
|
691
694
|
url: post.permalink ? `https://www.reddit.com${post.permalink}` : null,
|
|
692
695
|
flair: post.link_flair_text ?? null,
|
|
693
696
|
over_18: Boolean(post.over_18),
|
|
@@ -713,7 +716,7 @@ export const TEMPLATES = [
|
|
|
713
716
|
stories.push({
|
|
714
717
|
id: $row.attr('id'),
|
|
715
718
|
title: $titleLink.text().trim(),
|
|
716
|
-
url: $titleLink.attr('href'),
|
|
719
|
+
url: safeHref($titleLink.attr('href')),
|
|
717
720
|
site: $row.find('.sitebit a').text().trim() || null,
|
|
718
721
|
score: $score.text().replace(' points', '').trim() || null,
|
|
719
722
|
author: $subtext.find('.hnuser').text().trim() || null,
|
|
@@ -738,10 +741,10 @@ export const TEMPLATES = [
|
|
|
738
741
|
name: attr($, 'meta[property="og:title"]', 'content'),
|
|
739
742
|
tagline: attr($, 'meta[property="og:description"]', 'content'),
|
|
740
743
|
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
741
|
-
url: attr($, 'meta[property="og:url"]', 'content'),
|
|
744
|
+
url: safeHref(attr($, 'meta[property="og:url"]', 'content')),
|
|
742
745
|
votes: text($, '[data-test="vote-button"] span') || text($, 'button[data-vote-button]'),
|
|
743
746
|
topics: list($, 'a[href*="/topics/"]'),
|
|
744
|
-
website: attr($, 'a[data-test="product-link"]', 'href') || attr($, 'a[href][rel="noopener"][target="_blank"]', 'href')
|
|
747
|
+
website: safeHref(attr($, 'a[data-test="product-link"]', 'href') || attr($, 'a[href][rel="noopener"][target="_blank"]', 'href'))
|
|
745
748
|
};
|
|
746
749
|
}
|
|
747
750
|
},
|
|
@@ -869,8 +872,8 @@ export const TEMPLATES = [
|
|
|
869
872
|
version: latest,
|
|
870
873
|
description: release.description || doc.description || null,
|
|
871
874
|
license: npmLicense(release.license ?? doc.license),
|
|
872
|
-
homepage: release.homepage || doc.homepage
|
|
873
|
-
repository: npmRepositoryUrl(release.repository || doc.repository),
|
|
875
|
+
homepage: safeHref(release.homepage || doc.homepage),
|
|
876
|
+
repository: safeHref(npmRepositoryUrl(release.repository || doc.repository)),
|
|
874
877
|
bugs: npmBugsUrl(release.bugs || doc.bugs),
|
|
875
878
|
keywords: release.keywords || doc.keywords || [],
|
|
876
879
|
maintainers: (doc.maintainers || [])
|
package/src/urls.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL scheme filtering for values that come from scraped page content or a
|
|
3
|
+
* third-party payload — both untrusted.
|
|
4
|
+
*
|
|
5
|
+
* Such a URL flows into two places that will act on it: a React `href` (React
|
|
6
|
+
* does not sanitise href attributes) and an LLM client that may follow links.
|
|
7
|
+
* A `javascript:` or `data:` value there becomes a live XSS vector or an
|
|
8
|
+
* actionable prompt-injection URL. safeHref keeps only what is safe to hand on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ASCII control characters (codepoints 0-31 and 127). A browser ignores these
|
|
12
|
+
// inside a URL, so they can disguise a scheme, e.g. "java\tscript:alert(1)".
|
|
13
|
+
// Built from char codes so the source stays plain ASCII (no literal controls).
|
|
14
|
+
const CONTROL_CHARS = (() => {
|
|
15
|
+
let chars = '';
|
|
16
|
+
for (let c = 0; c <= 31; c++) chars += String.fromCharCode(c);
|
|
17
|
+
chars += String.fromCharCode(127);
|
|
18
|
+
return new RegExp('[' + chars + ']', 'g');
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Return the URL unchanged when it is an http(s) absolute URL or a scheme-less
|
|
23
|
+
* value (a relative path or a protocol-relative `//host` URL — neither carries a
|
|
24
|
+
* scheme to abuse), and null for everything else, including `javascript:`,
|
|
25
|
+
* `data:`, `vbscript:`, `file:`, `mailto:` and `tel:`.
|
|
26
|
+
*
|
|
27
|
+
* Control characters are stripped before the scheme is read, so a value like
|
|
28
|
+
* `java\nscript:alert(1)` — which a browser would execute after ignoring the
|
|
29
|
+
* newline — cannot masquerade as scheme-less and slip through.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} url
|
|
32
|
+
* @returns {string|null}
|
|
33
|
+
*/
|
|
34
|
+
export function safeHref(url) {
|
|
35
|
+
if (typeof url !== 'string') return null;
|
|
36
|
+
const cleaned = url.replace(CONTROL_CHARS, '').trim();
|
|
37
|
+
if (!cleaned) return null;
|
|
38
|
+
// A leading scheme is a letter followed by letters/digits/+/-/. and a colon.
|
|
39
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return cleaned; // relative / protocol-relative
|
|
40
|
+
return /^https?:\/\//i.test(cleaned) ? cleaned : null; // absolute: http(s) only
|
|
41
|
+
}
|