Reference
Security
What BooshCMS can reach, what it cannot, and what it relies on you to get right — including the parts that are weaker than they look.
What BooshCMS can reach, what it cannot, and what it relies on you to get right. Everything here is stated plainly, including the parts that are weaker than they first appear.
The short version
| A client can only reach repositories they already have access to. | Signing in delegates their existing GitHub access. It never grants new access. |
| Access control lives on GitHub, not in this app. | Give a client access to their own site and nothing else, and that is exactly what the app can reach. |
| Multi-site access is intended. | A marketing person running four client sites, or agency staff, should see all four. The app is not trying to reach one repository. |
SECURITY: what signing in actually grants
The client authorises their own GitHub account. The token is minted by GitHub and stored on their machine. Nothing routes through Boosh, and the app’s publisher never sees it.
The scope requested is repo + read:user. repo is the narrowest scope that
can read and write a private repository, and client sites are private.
A client cannot see another agency’s repositories, or another client’s, unless somebody gave that GitHub account access to them. The token inherits the account’s existing permissions; it does not expand them. If a client could not browse a repository at github.com before signing in, the app cannot reach it either.
In the ordinary setup this is simply correct: the account has their site, and the app can reach their site.
Two topologies, both fine
Developer owns the repository. The developer keeps the site in their own account and adds the client as an outside collaborator on that one repository. Simple, and the developer keeps continuity across clients.
Client owns the repository. The client’s account owns the site and the developer is a collaborator on it. Handover is already done, and the client can revoke the developer at any time.
The second is the better fit for what this product claims — the content is genuinely theirs, not held on their behalf — and it is worth offering as the default at handover.
SECURITY: the one case where scope breadth matters
repo reaches every repository that account can write to. Where the account
exists for this purpose, that is their site and nothing else, and there is
nothing further to say.
It matters in one situation: the client’s GitHub account is also used for something that matters — most plausibly a marketing employee at a larger company whose account can also reach the company’s own code. The app confines itself to the open project, but that is a property of this code rather than something GitHub enforces, so a token that could reach more is worth knowing about.
The mitigation is a separate GitHub account for editing the website, which is what most clients will end up with anyway.
Why not a GitHub App? A GitHub App can be installed on a single repository and its token reaches only that installation, which would close this. It is not obviously better here: it must be installed per repository, so somebody editing four sites needs four installations, and the developer has to install it at every handover. Confinement is bought with friction, and the case it protects against is narrow. Worth revisiting if a licensee’s clients are the kind of organisation where it matters.
SECURITY: how the token is stored and used
-
At rest — encrypted by the operating system’s keychain via Electron’s
safeStorage(Keychain on macOS, DPAPI on Windows). Where encryption is unavailable, nothing is written at all rather than a token being left in plain text; the client signs in again next launch. -
The keychain is never opened while the app is starting.
safeStorageis not a passive API: on macOS even the availability check reaches into the keychain, and the OS asks the user for permission when the app’s signature is not the one that wrote the entry. An ad-hoc signed build gets a new signature every build, so the dialog returns with every beta — and it says “safe storage”, which means nothing to a client who was trying to open their website.Loading the session at startup therefore put that dialog in front of every launch. A file check was added first and fixed only half the problem: someone who had never signed in stopped being asked, but anyone who had signed in was still prompted every time, because startup decrypted the store to read the account name off it.
The account is not a secret, so it is no longer kept with the secrets.
github-account.jsonholds the login, display name and avatar URL in plain text — all of it public on the person’s GitHub profile — and that is what the app reads at launch to show signed in as jo. The tokens stay sealed in the encryptedgithub-sessionuntil something actually needs one, at which point the client is plainly doing something involving GitHub and a permission dialog makes sense.Both files must be present to count as signed in; an account file with no session would be a half-signed-in state that failed at the first publish. Signing out removes both. A session written by an older build with no account file beside it heals itself on first use, without prompting at launch.
Pinned by
tests/github-auth-startup.test.ts, which fails if startup callssafeStorageat all. -
Expiry — access tokens expire after a few hours and are renewed silently with a refresh token. A stolen laptop stops being useful quickly.
-
In use by git — passed as a per-invocation
-c http.extraheader. It is never written to.git/config, never embedded in a remote URL, and never handed to a credential helper.An early version used
addConfig, which would have written the token into the client’s own repository config in plain text, where it would have sat indefinitely. Caught by the type checker before it shipped. Do not reintroduce it. -
Residual exposure — the token appears in the git process’s arguments, so it is visible to
psfor the same user on that machine. That user already has the app and can already read the token, so the exposure is nil in practice. Worth knowing on a shared machine.
SECURITY: signing out
Sign out deletes the stored session from disk. It does not revoke the token at GitHub. To revoke properly the client goes to GitHub → Settings → Applications → Authorized OAuth Apps → BooshCMS → Revoke.
A lost or stolen machine should be handled by revoking there, not by signing out here.
Practice expectations for developers
These are how the access control is actually achieved. The app cannot enforce them, and none of them are onerous — they are what a sensible setup looks like anyway.
1. One repository per client site
Not because sharing one is insecure in some subtle way, but because access on GitHub is granted per repository. Two clients in one repository can both reach all of it.
2. Grant access per repository, not organisation-wide
Adding a client as an outside collaborator on their repository gives them that repository. Adding them to an organisation that owns your other client work can give them far more.
If you use an organisation, set base permissions to “No permission” (Organisation → Settings → Member privileges) and grant per repository. The default is more permissive than most people expect — this is the one setting that catches people out.
3. Give them write access, not admin
Write is enough to publish. Admin lets them change settings, manage collaborators and delete the repository.
4. Consider review mode for clients you do not want publishing directly
studio.config.json with "mode": "review" sends their work to a branch you
merge. It is not a security control — they still have write access — but it puts
a person between the client and the live site.
5. Do not put secrets in the repository
The client can read every file in it, and so can the app. Form endpoints, API keys and tokens belong in your host’s environment variables, never in the site’s files or in a component prop.
SECURITY: licensing
Ported from FEWD Studio, whose central decision is kept: no credentials ship in the app. Lemon Squeezy’s licence endpoints are public and take no API key — the licence key itself is the credential. Nothing secret is bundled, and if a future endpoint ever needs a secret it belongs behind a proxy we host, never in the package.
SECURITY: a key must belong to this product
Those endpoints will validate any key from any of our stores. Without a check, a key bought for one app activates another — and FEWD Studio has no such check today, so a FEWD key would unlock this app if the code were copied across unchanged.
src/core/licence/product.ts compares the store_id and product_id Lemon
Squeezy returns against a manifest committed in the app, on activation and on
every revalidation — the second because a license.json can be carried over
from another app or edited by hand. Lemon Squeezy’s own guidance is to hard-code
those ids and do exactly this. They are not secret; they appear in checkout
URLs.
An activation that turns out to be for the wrong product is released again rather than left consuming a seat on a key that was never going to work here.
SECURITY: the key at rest — plain text, on purpose
license.json in app.getPath('userData') — that spelling in every app in this
collection, because FEWD already ships it and renaming would orphan real
activations. userData is already per-application, so two of our apps on one
machine cannot collide.
The key is not encrypted, and that is a decision rather than an oversight.
It was briefly stored with safeStorage, by analogy with the GitHub token
above. The analogy is wrong: a GitHub token grants access to every repository
the account can reach, while a licence key is emailed to the customer, sits in
their Lemon Squeezy account, and unlocks nothing but this app on this machine.
Encrypting a person’s own key against that person protects nobody.
It also added a real failure. encryptString throws where the OS offers no
backend, and it sat outside the write’s error handling — so a machine that could
not encrypt could not activate at all, and the licence screen hung rather than
saying so.
The Licence panel shows only the last four characters, which is the part that was worth keeping: it stops a screen share giving the key away.
SECURITY: a network failure never revokes
Only a server that answers and says no takes a licence away. A refusal and an unreachable server are different answers and are kept different throughout. Someone offline keeps working for a 30-day grace period and is then asked to connect — not told they are unlicensed. An app that locks out a paying customer because their wifi is down is a worse failure than one that trusts a cancelled key for a month.
Releasing a device follows the same rule, and this is FEWD’s logic unchanged: the local licence is cleared only when the server confirms, or when the instance is already gone at their end. On a network failure it is kept — clearing it then would cost the activation here while the seat stayed consumed there.
SECURITY: the development bypass
BOOSH-TEST-DEV-GOOD activates without contacting anyone, and is guarded by
!app.isPackaged, so it is inert in anything shipped.
SECURITY: the desktop app itself
contextIsolation: trueandnodeIntegration: falseon every window. These are non-negotiable and there is no configuration that turns them off.- The renderer reaches the machine only through the preload bridge — a fixed set of named calls, each validated in the main process.
- Every filesystem path from the renderer is resolved and checked to be inside the open project before any read or write.
- Folder names and paths in the media library get two independent checks,
because both arrive from the client — typed into a box, or over the bridge
where a compromised renderer could send anything.
src/core/media/folders.tsrejects..in every shape, absolute paths, backslashes, NUL, empty segments, hidden segments and anything past a depth cap; then the main process resolves the path and confirms it really does sit under the media root. One is a rule about strings, the other a fact about the resolved path. Seventeen tests cover the first; the second is verified against real directories, where seven traversal attempts are refused by both listing and ingest with nothing written outside the root. - Symbolic links in the media library are skipped, not followed — one could point anywhere on the machine — and hidden entries are never listed.
- What may be added at all is an allow-list: images, documents, media, fonts and icons. An executable or a script is refused rather than filed. This is a client’s website folder and an allow-list is the only way it stays one.
- A Content Security Policy limits the app’s own page to its own resources.
- The live preview runs in a separate
WebContentsViewwith its own origin and its own process. It cannot reach the app’s renderer. Links inside it open in the real browser rather than navigating the panel. - Links from anywhere in the app are opened externally rather than followed
in-window, and the sign-in helper refuses any address that is not
https://github.com/….
SECURITY: third-party code on the client’s machine
The live preview needs the site’s own dependencies installed, which is a real install of other people’s code on a machine you will never see. It is gated rather than silent:
- Nothing installs without an explicit confirmation that names the package count, the exact packages, the registry, and the rough size.
- Every version in the lockfile is checked against the registry’s record of when it was published, before anything downloads. Anything published inside the last seven days is listed by name and the confirm button changes to “Install anyway”. Newly published packages are where supply-chain attacks live; a compromised release is usually caught and pulled within days.
- Where the developer has not committed a lockfile, resolution happens on the
client’s machine — so
--beforeis passed to hold it a week back. npm ciis used when a lockfile exists, so the developer’s exact versions are installed and nothing is resolved afresh.npm auditruns afterwards and reports known problems in plain language.
A lockfile guarantees reproducibility, not safety.
npm ciwill happily install a package the developer added the day it was published. That is why the lockfile itself is checked, not just unpinned installs.
Git is also run with hooks disabled (core.hooksPath pointed at nothing), so a
repository cannot execute code on the client’s machine through a hook.
SECURITY: what is not protected
Stated so nobody assumes otherwise.
- The client can read every file in the repository, including anything the developer left there.
- The app’s write restriction is not enforced by GitHub. It only writes content files, but the token could reach anything that account can. This is fine when the account exists for this purpose; see above for when it is not.
- There is no audit trail beyond git history. Who changed what is whatever the commits say.
- No multi-factor policy is imposed. If the client’s GitHub account has weak authentication, that is the weak point, and it is outside this app.
- A malicious developer can do anything, since they control the components, the schema and the site’s build. The trust model assumes the developer is trusted by their client; this app protects the client from accidents, not from their own developer.