santifer/career-ops · error
builtin: invalid URL
Error message
builtin: invalid URL: ${url} What it means
The builtin (Built In) provider's assertHost SSRF guard throws this when the URL cannot be parsed by new URL(). All builtin provider request URLs pass through this guard, which re-checks the resolved host against the allowlist rather than trusting the caller. A malformed URL is rejected before any network request.
Solutions
- Fix the host value in portals.yml to a bare allowlisted host such as 'www.builtinseattle.com' (no scheme, no path).
- Test the string with `new URL('https://' + host + '/jobs')` in node to reproduce the parse error.
- Trim invisible characters (BOM, non-breaking spaces) from the config value.
- If composing URLs in code, validate the host with the exported resolveHost() helper before building the URL.
Example fix
// before
const url = `https://${cfg.host}/jobs`; // cfg.host = 'https:/builtinseattle.com'
// after
const host = resolveHost(cfg.host); // 'www.builtinseattle.com'
if (!host) throw new Error('bad host config');
const url = `https://${host}/jobs`; Defensive patterns
Strategy: validation
Validate before calling
import { resolveHost } from './providers/builtin.mjs';
const host = resolveHost(cfg.host);
if (host === null) throw new Error(`config: builtin host not allowlisted: ${cfg.host}`); Type guard
function isPlainHost(v) { return typeof v === 'string' && v.trim() !== '' && !v.includes(' '); } Try / catch
try {
await provider.fetch(entry, ctx);
} catch (err) {
if (String(err.message).startsWith('builtin: invalid URL')) {
console.warn(`Skipping ${entry.name}: unparseable builtin host config`);
return null;
}
throw err;
} Prevention
- Store bare hostnames (no scheme, no path) in the builtin host config field.
- Validate every host with the exported resolveHost() helper before composing request URLs.
- Strip whitespace and invisible characters from config values at load time.
- Avoid interpolating possibly-undefined variables into URL templates.
When it happens
Trigger: A request URL built from a portals entry whose host string is so malformed that even resolveHost-style normalization fails downstream, or an internally composed URL with a bad scheme/path (e.g. missing origin, embedded spaces) reaching assertHost.
Common situations: Config value containing a full path or garbage that slips past resolveHost's tolerant parsing; interpolation bug producing 'https://undefined/jobs'; pasted URL with control characters or a typo like 'https:/builtinseattle.com' (single slash).
Understand the failure class
Background: "Invalid URL" errors: why new URL(), URI.parse, and reqwest::Url reject your string — missing scheme, whitespace, and bad path format — this error's family across 39 libraries.
Related errors
- breezy: invalid URL
- careerviet: invalid URL
- eightfold: invalid URL
- arbeitnow: invalid URL
- ashby: invalid URL
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/cfd66cbd0d27dc21.
Report an issue: GitHub.
Appendix: source
Thrown at providers/builtin.mjs:169
}
return HOSTS.get(h) ?? null;
}
/**
* SSRF guard — every request URL passes through here before it is fetched. The
* host comes from config, so this is the only thing standing between a
* portals entry and an arbitrary fetch target. It checks the RESOLVED host
* against the allowlist again rather than trusting the caller.
*
* @param {string} url
* @returns {string}
*/
function assertHost(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error(`builtin: invalid URL: ${url}`);
}
if (parsed.protocol !== 'https:') throw new Error(`builtin: URL must use HTTPS: ${url}`);
const host = parsed.hostname.toLowerCase();
if (HOSTS.get(host) !== host) {
throw new Error(`builtin: untrusted hostname "${parsed.hostname}" — must be one of ${[...new Set(HOSTS.values())].join(', ')}`);
}
return url;
}
/** @param {string} s */
function stripTags(s) {
return decodeEntities(String(s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
}
/**
* Text of the first element following an icon marker inside a card.
* Anchoring on the icon class (rather than on field order) is what keeps this
* readable when Built In reshuffles the card layout.View on GitHub (pinned to aac998c7ed)