santifer/career-ops · error · Error
plugin egress to " " is not in allowedHosts [ ]
Error message
plugin egress to "${u.hostname}" is not in allowedHosts [${[...allow].join(', ')}] What it means
hostOk also enforces an allowlist: when the plugin declares allowedHosts (non-empty), any fetch whose URL hostname is not in that set throws before the request leaves the process. This is the least-privilege egress policy — plugins can only talk to hosts they explicitly declared.
Solutions
- Add the target hostname to the plugin's allowedHosts in its manifest/config (exact hostname match, no scheme or path)
- Verify the hostname spelling matches the URL exactly (subdomains must be listed explicitly)
- If the fetch is unexpected, audit the plugin code — a request you didn't anticipate may be exfiltrating data
Example fix
// config: allowedHosts: ['api.github.com']
// before
await ctx.fetch('https://raw.githubusercontent.com/user/repo/main/x.json');
// Error: plugin egress to "raw.githubusercontent.com" is not in allowedHosts [api.github.com]
// after: allowedHosts: ['api.github.com', 'raw.githubusercontent.com']
await ctx.fetch('https://raw.githubusercontent.com/user/repo/main/x.json'); Defensive patterns
Strategy: validation
Validate before calling
const u = new URL(target);
const allowed = pluginConfig.allowedHosts ?? [];
if (allowed.length > 0 && !allowed.includes(u.hostname)) {
throw new Error(`host ${u.hostname} not in allowedHosts — add it to the plugin config first`);
} Type guard
const hostIsAllowed = (url, allow) => allow.length === 0 || allow.includes(new URL(url).hostname);
Try / catch
try { return await ctx.fetch(url); } catch (e) { const m = e.message.match(/plugin egress to "([^"]+)" is not in allowedHosts/); if (m) { throw new Error(`${e.message} — add "${m[1]}" to allowedHosts or remove this call`); } throw e; } Prevention
- Declare every host a plugin will contact at manifest time, including CDNs and secondary APIs
- Audit plugin upgrades for newly added endpoints and update allowedHosts
- Treat unexpected allowlist errors as a security signal — verify the plugin isn't calling hosts you never approved
When it happens
Trigger: A plugin calling ctx.fetch() against a hostname not listed in its allowedHosts config; a provider plugin redirected/updated to a new API domain that was never added to the allowlist.
Common situations: A plugin fetching a CDN, webhook, or secondary API domain beyond its declared hosts; typos in allowedHosts entries; a plugin upgrade adding new endpoints while the config still lists old domains.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
- plugin egress must use HTTPS
- Access denied: Egress guard blocked private target IP
- Access denied: Egress guard blocked private target IP
- arbeitnow: untrusted hostname
- ashby: untrusted hostname
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/89fd92abe7275459.
Report an issue: GitHub.
Appendix: source
Thrown at plugins/_engine.mjs:394
* the allowlist + per-hop re-validation + cross-host credential strip bound it.
*
* ADVISORY only: this binds a plugin that routes through ctx.fetch*, not one
* that calls global fetch directly (see the trust note in README.md).
*
* @param {string[]} allowedHosts
*/
function makeGuardedFetch(allowedHosts, { allowsLocalhost = false } = {}) {
const allow = new Set(allowedHosts);
const isLoopbackHost = (h) => /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(h);
const hostOk = (u) => {
if (u.protocol !== 'https:') {
// Plain HTTP is allowed ONLY for an opted-in loopback host (local-AI
// providers like Ollama/LM Studio serve http://localhost:11434).
if (!(allowsLocalhost && u.protocol === 'http:' && isLoopbackHost(u.hostname))) {
throw new Error(`plugin egress must use HTTPS: ${u.href}`);
}
}
if (allow.size > 0 && !allow.has(u.hostname)) throw new Error(`plugin egress to "${u.hostname}" is not in allowedHosts [${[...allow].join(', ')}]`);
};
return async function guardedFetch(url, opts = {}) {
const { timeoutMs = 10_000, headers = {}, method = 'GET', body = null } = opts;
let current = new URL(url);
hostOk(current);
// SSRF: reject a host that resolves to a private/loopback/metadata address
// (re-checked on every redirect hop). Loopback allowed only when opted in.
await resolveAndValidate(current.hostname, { allowsLocalhost });
let reqHeaders = { ...headers };
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetch(current.href, {
method, headers: reqHeaders, body, redirect: 'manual', signal: controller.signal,
});
} finally {View on GitHub (pinned to aac998c7ed)