An opt-in flag that can't quietly grow teeth
Shipped
v0.3.0 hardens the résumé skill’s job-extraction path in four ways: LinkedIn job URLs can now route through the same Firecrawl stealth path already used for Indeed and Glassdoor, but only behind an explicit RESUME_ALLOW_LINKEDIN=1 opt-in, since LinkedIn actively pursues scrapers. A failed extraction now prints a plain “could not fetch this job posting automatically, paste the job description text instead” message rather than a raw internal error string. The post-render style picker became a mandatory step instead of a conditional one, and the default output directory moved to a stable ~/resume-out, independent of where the skill happens to be installed.
The part worth pulling apart is how the LinkedIn opt-in is wired up. The obvious way to write it is to ask the same domain classifier everything else in the pipeline already asks: “is this hostname on the hostile list, and if so, is the user allowed to override it?” That’s a one-line check, it reads cleanly, and it’s wrong in a way that won’t show up until someone else, working on something unrelated, adds a second domain to the same list.
The trap: an opt-in that reads the list instead of a name
Say your extraction pipeline keeps one shared set of domains it refuses to fetch normally:
// classify.mjs: one shared category, populated over time by different
// people for different reasons. Nothing here knows about opt-in flags.
export const HOSTILE_HOSTS = new Set(["linkedin.com", "www.linkedin.com"]);
export function classify(hostname) {
return HOSTILE_HOSTS.has(hostname) ? "hostile" : "ok";
}
Now you add an opt-in that lets a user override the block for LinkedIn specifically. The natural first draft asks the classifier’s category, not the name:
// gate.mjs: the trap
import { classify } from "./classify.mjs";
export function allowNaive(hostname, optIn) {
return classify(hostname) !== "hostile" || optIn;
}
This passes every test you’d think to write today, because HOSTILE_HOSTS only has one entry. The bug is dormant, not absent. Six months from now, someone blocks a scraper-hostile job board that has nothing to do with LinkedIn, adds it to HOSTILE_HOSTS, and moves on. They never touch gate.mjs. The opt-in flag, whose entire job was “let a user through on LinkedIn specifically,” now silently un-blocks the new domain too, for anyone who happened to set that flag for an unrelated run. Nobody decided that. The coupling did.
Build it: scope the opt-in to a name of its own
The fix isn’t a smarter classifier, it’s decoupling the opt-in from the category entirely. Martin Fowler’s write-up on feature toggles makes the general case for this: a toggle should not need to know what its neighbors’ categories mean, because the moment it does, changes to one feature start silently reaching into another (“Why should the invoice emailing code need to know that the order cancellation content is part of the next-gen feature set?”). The opt-in gets its own explicit list, sized to exactly the thing it’s meant to override:
// gate.mjs: the fix
import { classify } from "./classify.mjs";
const LINKEDIN_STEALTH_OPT_IN_HOSTS = new Set(["linkedin.com", "www.linkedin.com"]);
export function allowScoped(hostname, optIn) {
return (
classify(hostname) !== "hostile" ||
(optIn && LINKEDIN_STEALTH_OPT_IN_HOSTS.has(hostname))
);
}
allowScoped still asks classify whether the domain is blocked by default, that part of the logic is unchanged. What changed is what the opt-in is allowed to unlock: a fixed, explicit set that nobody editing HOSTILE_HOSTS for an unrelated reason will ever touch. This is also the shape Saltzer and Schroeder’s fail-safe defaults principle argues for at the access-control level generally, base the decision on an explicit grant rather than an absence of exclusion, because a grant-based check fails by denying access when something’s wrong, and an exclusion-based check fails by quietly letting something through (“Base access decisions on permission rather than exclusion.”). LINKEDIN_STEALTH_OPT_IN_HOSTS is a permission list, not an exclusion carve-out.
Verify it: prove the leak, then prove it’s closed
Code that only looks correct today needs a test that simulates tomorrow, specifically the moment someone adds an unrelated entry to the shared list:
// gate.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { HOSTILE_HOSTS } from "./classify.mjs";
import { allowNaive, allowScoped } from "./gate.mjs";
test("today: both gates behave the same for linkedin.com", () => {
assert.equal(allowNaive("linkedin.com", true), true);
assert.equal(allowScoped("linkedin.com", true), true);
});
test("six months later: someone adds an unrelated hostile host", () => {
HOSTILE_HOSTS.add("scam-jobs.example");
// The naive gate leaks: the old opt-in now silently un-blocks a domain
// nobody meant to expose.
assert.equal(allowNaive("scam-jobs.example", true), true);
// The scoped gate holds: its allowlist never grew, so the new hostile
// host stays blocked regardless of the opt-in.
assert.equal(allowScoped("scam-jobs.example", true), false);
});
Run it with Node’s built-in test runner:
node --test gate.test.mjs
That produces:
✔ today: both gates behave the same for linkedin.com (0.59175ms)
✔ six months later: someone adds an unrelated hostile host (0.094625ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
Both assertions in the second test pass, which is the point: the test isn’t checking that the code refuses to compile, it’s checking that allowNaive really does leak (and documenting exactly how) while allowScoped really does hold the line. If you swap allowScoped in for allowNaive in production and this test still exists, the day someone adds a new hostile host, the second assertion catches the regression before a user ever sees it.
Gotchas
The generic check is the version you’ll write first, and it’s the one to delete. Reading classify(hostname) === "hostile" inside an opt-in check feels like good reuse: one source of truth for “what’s blocked.” That instinct is exactly backwards for an override, because it means anyone extending the blocklist is unknowingly extending the override too. OWASP’s product-design guidance frames the same trade-off at the system level: establish secure defaults and fail back to a well-understood state rather than trusting a broad classification to stay narrow as it grows (OWASP Secure Product Design Cheat Sheet). If you’re writing an opt-in around a shared classification, grep for every place that reads the same category the opt-in touches, and check each one still means what you think it means once the category grows.
An identity check is only as strong as the string you’re comparing. A literal hostname match like hostname === "linkedin.com" can be walked around by a lookalike domain a user pastes without noticing, punycode-encoded Cyrillic characters that render as “linkedin.com” but resolve somewhere else entirely. The actual fix in this codebase rejects any hostname with an xn-- label before classification runs at all, so a spoofed domain never reaches the literal-string check in the first place. If your opt-in is gated on a name, normalize and reject lookalikes upstream of that comparison, not as an afterthought.
Sources
- Martin Fowler: Feature Toggles (aka Feature Flags) — a toggle shouldn’t need to know what a neighboring feature’s category means; coupling a decision point to shared category logic is how toggles reach into features they were never meant to touch.
- Saltzer & Schroeder: The Protection of Information in Computer Systems — the fail-safe defaults principle: base access decisions on an explicit grant rather than on the absence of an exclusion, because grant-based checks fail by denying access, not by silently allowing it.
- OWASP Secure Product Design Cheat Sheet — establish secure defaults and fail securely to a well-understood state, rather than trusting a broad classification to stay narrow as it grows.
Changelog
- feat(resume): extraction hardening, mandatory style loop, vendor-neutral rebrand (0.3.0) (#25) (ce9ee0b)