An HTTP header editor for Chrome. Add, modify and remove request and response headers, grouped into profiles and scoped to the sites you choose.
This is the point of the project, so it goes first.
Headsmith is built on declarativeNetRequest and nothing else. It hands the
browser a list of rules and the browser applies them. The extension is never
invoked for a request. It does not receive the URL, the headers, the body, the
response, or the fact that the request happened.
That is not a promise about our conduct. It is the shape of the API — there is no code path that could log your traffic, because none of our code runs when a request is made.
The alternative way to build this is a blocking webRequest listener, which
receives every request and every response header on every site it is permitted
to touch and must be trusted not to act on them. OpenModHeader's Firefox build
works that way — that is the one this project read, not a claim about the
category. Headsmith does not request webRequest, and it is on a hard-fail
list in CI so it cannot be added quietly.
You do not have to trust that the extension in the Web Store was built from this source. You can check.
git clone https://github.com/bcollard/headsmith.git && cd headsmith
git checkout v1.0.0 # the tag you want to verify
nvm use # matches .nvmrc — the Node version affects the output
npm ci
node scripts/verify-reproducible.mjs ~/Downloads/headsmith-1.0.0.zipThe build is a pure function of the source: fixed timestamps, sorted archive entries, no metadata from the build machine. Two builds of the same commit produce byte-identical archives, on any machine, in any directory.
And to confirm the artifact came from this repository:
gh attestation verify headsmith-1.0.0.zip --repo bcollard/headsmithThe shipped bundle is deliberately not minified. Publishing with provenance is worth little if the thing being attested is a 400KB unreadable chunk, so the JavaScript in the release is the JavaScript you can read.
This matters because of a gap most extensions leave open: the Chrome Web Store
signs the .crx itself from an uploaded .zip. The developer never signs
anything, and nothing links the published bytes to a commit. Reproducibility
plus attestation is what closes it.
- Request and response headers — set, append, remove.
- Profiles — group rules, switch between them, enable and disable individually, pause everything with Alt+Shift+H.
- Scoping — by domain, URL substring, URL regex, and resource type, with per-profile domain exclusions and a global "never touch these URLs" list.
- Credential handling — header values recognised as credentials are stored separately from the profile, either in session-only memory or in a passphrase-encrypted vault.
Chrome's declarativeNetRequest cannot read an existing header value. That
rules some things out, and Headsmith says so rather than shipping a degraded
version under a name that implies otherwise:
- No cookie merge. True merge means "overwrite this one cookie, keep the
rest", which requires reading the outgoing
Cookieheader. Not possible. - No CSP directive merge. Same reason: a policy can be replaced wholesale, not surgically edited.
- No traffic inspection, by construction.
Six invariants, each enforced by a CI job rather than a comment:
| Invariant | Enforced by |
|---|---|
| No network egress, ever | A guard that scans the built bundle for fetch, XMLHttpRequest, WebSocket, sendBeacon, EventSource and native messaging |
| No remote subresources | The same guard, over every built HTML and CSS file |
| No plaintext secrets at rest | A test that writes a credential and asserts the cleartext appears nowhere in a dump of storage |
storage.sync never sees a secret |
There is no storage.sync code path at all |
| No dynamic code execution | Lint rules plus MV3's own CSP |
| Least permission | A guard that diffs the built manifest against a reviewed baseline |
The guards read dist/, not src/, and that distinction is deliberate — the
bug they were modelled on is a remote font stylesheet that is invisible in a
source tree and only appears in build output.
Three permissions at install — declarativeNetRequest, storage, alarms —
and no host access at all. The install prompt says nothing about websites.
Host access is requested one domain at a time, when a profile first names one.
Grant api.example.com and that is what Chrome asks about; it is revocable
per-site from Chrome's settings. A profile scoped only by URL text or a regex
can match any host, so those ask for broad access explicitly and say why.
Settings → Site access shows exactly what is currently granted, lets you remove any of it, and carries a deliberate Allow all sites switch for when granting domain by domain is not worth the trouble. Granting is never one-way: anything allowed there can be withdrawn there.
Each entry is justified in
scripts/permissions-baseline.json.
Four runtime dependencies: React, React DOM, Zod, and React's scheduler. Everything else in the tree is build-time only and never reaches your browser.
Header values that look like credentials — Authorization, X-Api-Key,
anything matching the patterns in
src/core/sensitivity.ts — are never stored inside a
profile. The profile holds a reference; the value lives in the secret store.
Two modes:
- Session only (default) — values live in
storage.session, which Chrome clears when the browser exits. Nothing touches disk. You re-enter credentials after a restart. - Encrypted vault — AES-GCM under a key derived from your passphrase with
PBKDF2-SHA256 at 600,000 iterations. Ciphertext in
storage.local, key instorage.sessiononly, dropped on lock, with an idle auto-lock.
There is no persistent-plaintext mode.
Two behaviours worth knowing:
- Fail closed. If a credential cannot be resolved, the operation needing it
is skipped and the rest of the profile still applies. An empty value is never
substituted — a request carrying
Authorization:with nothing after it is worse than one carrying noAuthorizationat all. - A credential needs a scope. A profile carrying one must name a domain or URL before its rules apply, so a production token cannot be attached to every request your browser makes. Overridable per profile, never globally.
Not yet published, so run it from source:
npm ci
npm run buildLoad it into Chrome:
- Open
chrome://extensions(ormake load) - Turn on Developer mode, top right
- Load unpacked → select
dist/chrome
Then check it actually does something. In a second terminal:
npm run echo # a local echo server on http://localhost:8787Open http://localhost:8787. It lists the request headers the server really received, with anything your browser would not normally send called out separately. It is local on purpose — testing a privacy extension should not mean posting your headers to somebody else's server, and a credential you are experimenting with should stay on your machine.
Now open the Headsmith popup, add a header — say X-Environment: staging — and
under Scope put localhost in Domains. Reload the echo page and it will be
there.
A few things worth trying, because they are where the behaviour is opinionated:
- Name a header
Authorization. The value field is replaced by a credential field the moment the name is recognised. What the profile stores is a reference; the value goes to the secret store. - Leave the scope empty on that profile. It refuses to apply and says why — a credential must name where it is allowed to go.
- Set a response header. Then read the next section, because checking this one is genuinely counter-intuitive.
- Press Alt+Shift+H. Everything stops, and nothing is lost.
After a rebuild, click the reload icon on the extension card in
chrome://extensions.
This is the single most confusing thing about any extension that modifies response headers, so it is worth stating plainly.
DevTools does not show them. The Network panel reports response headers as they arrived from the server, before extensions modify them. A header you set here can be applied correctly and still be absent from that list. Nothing is wrong.
fetch(...).headers.get(...) does not show them either, on a cross-origin
page. CORS exposes only a safelisted set of response headers to JavaScript, so
a custom one reads as null whether or not it arrived.
What does work: open the console on the page itself and ask for the page again.
(await fetch(location.href)).headers.get('Your-Header')That request is same-origin, so the browser exposes every response header to it, and you get the real value back. The earlier caveat is precisely about cross-origin requests; this is not one.
One wrinkle: that is a new request, of type xmlhttprequest. If you have
restricted a profile to main_frame under Request types, the check will not
match even though the page load does. With Request types left unticked — the
default — it is representative.
If you cannot run script on the page, use a header with a visible effect
instead. Setting Content-Type to text/plain on a JSON endpoint makes the
page render as raw text; Chrome had to have read the modified header to parse
it that way.
The local echo server covers the same ground for anything you can point at localhost.
make help # what you can do
make dev # hot-reloading development build
make check # everything CI runs, in the same order
make e2e # end-to-end against a real loaded ChromeThe architecture in one line: src/core decides, src/platform touches the
browser, and src/core may not import chrome — enforced by a lint rule and
a CI guard. That is what lets the rule compiler be snapshot-tested against JSON
fixtures in milliseconds, without a browser.
See CONTRIBUTING.md.
Headsmith is not a fork, but a substantial part of it is derived work, and the credential-security model in particular is a TypeScript port of OpenModHeader's. The snapshot-fixture testing pattern comes from FlexHeader. Both are MIT licensed.
NOTICE.md itemises every derived file, what came from where, and what changed — in enough detail to check rather than take on trust.