Skip to content

Repository files navigation

Headsmith

An HTTP header editor for Chrome. Add, modify and remove request and response headers, grouped into profiles and scoped to the sites you choose.

bcollard.github.io/headsmith


Headsmith cannot see your traffic

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.

Verify the build yourself

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.zip

The 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/headsmith

The 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.

What it does

  • 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.

What it deliberately does not do

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 Cookie header. Not possible.
  • No CSP directive merge. Same reason: a policy can be replaced wholesale, not surgically edited.
  • No traffic inspection, by construction.

Security

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.

Credentials

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 in storage.session only, 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 no Authorization at 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.

Try it locally

Not yet published, so run it from source:

npm ci
npm run build

Load it into Chrome:

  1. Open chrome://extensions (or make load)
  2. Turn on Developer mode, top right
  3. 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:8787

Open 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.

Response headers are hard to verify, and both obvious methods lie

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.

Development

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 Chrome

The 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.

Prior art, honestly

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.

Licence

MIT. See LICENSE and NOTICE.md.

About

An HTTP header editor for Chrome. Add, modify and remove request and response headers, grouped into profiles and scoped to the sites you choose. declarativeNetRequest-only, so it cannot see your traffic.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages