Project
Hardening the app: a security pass on my tab counter
A follow-up to Browser Tab Counter — a full security review of the code and its release pipeline, and the fixes it prompted.
I wanted to add a functionality to my little menu-bar tab counter and took the opportunity to run a full security review of the code and environment. A few prompts with my friend Claude showed that the base was already quite solid.
The first question I had was about leaked secrets in my GitHub repo. A scan of every version of every file ever committed showed that nothing had ever leaked. The signing certificate and passwords only ever lived in GitHub’s encrypted secrets, exactly where they belong.
Still, there were a few things that could be improved. Here’s a quick rundown of my favourites, in plain language.
1. Don’t show your secrets to strangers’ code
My signing certificate is stored as an encrypted secret that is used in a GitHub Action. But I had stored it in the job-wide environment, which means every step could read it, including that third-party code. Oops.
Before:
jobs:
build:
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} # visible to ALL steps
steps:
- uses: softprops/action-gh-release@v2 # ...including this one
After: the secret is only handed to the one step that actually imports the certificate, and the third-party action is pinned to an exact commit (so the code I audited is the code that runs, forever):
- name: Import Developer ID certificate
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} # this step only
run: ...
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # pinned
2. The self-updater/installer
One of the features added in a past release is the in-app updater, which makes a version upgrade a lot easier for the user. That, of course, introduces some new possible vulnerabilities.
What needed adjusting was limiting where the new package can be downloaded from: only my repo on GitHub.
# Before: download whatever URL we were handed
def _download(url, dest):
with urllib.request.urlopen(url) as r:
...
# After: HTTPS + GitHub only, and never more than 200 MB
def _check_url(url):
parsed = urllib.parse.urlparse(url)
host = (parsed.hostname or "").lower()
if parsed.scheme != "https" or not (
host in _ALLOWED_HOSTS or host.endswith(".githubusercontent.com")
):
raise UpdateError("unexpected update URL, refusing to download")
A fun detail: the test suite now includes the classic impostor
github.com.evil.example, which contains “github.com” but very much isn’t it.
Rejected.
Additionally, the updater now runs a full signature integrity check of the downloaded package, and there’s a file-size cap on the update-check response.
3. Dealing with config and log files
The app works with files hosted in the file system for different functionalities. These adjustments make sure they are handled more safely.
Alert threshold and preferences live in a tiny JSON file. A crash at exactly the wrong moment could leave it truncated and silently reset the settings. The fix is a classic: write to a temporary file, then atomically swap it in. The rename either fully happens or doesn’t happen at all.
# Before
PREFS_PATH.write_text(json.dumps(data))
# After: temp file + rename = all-or-nothing
tmp = PREFS_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(PREFS_PATH)
The Firefox tab count requires the app to decode Firefox’s compressed session file with a small decompressor. A corrupt (or malicious) file could make it quietly produce garbage, or chew through memory. Now it validates as it goes and refuses bad data loudly:
start = len(out) - offset
if offset == 0 or start < 0:
raise ValueError("corrupt LZ4 block (bad match offset)")
if len(out) + match_len > hard_cap:
raise ValueError("LZ4 output exceeds size cap")
The launch-at-login file is now XML-escaped, so an app path containing &
can’t produce a config that macOS silently ignores.
# Before: the path goes straight into the XML
args_xml = "\n".join(f" <string>{a}</string>"
for a in program_args)
# After: escape every value first
from xml.sax.saxutils import escape
args_xml = "\n".join(f" <string>{escape(a)}</string>"
for a in program_args)
What I learned
Running a code review like this is always a learning experience, and a reminder that code integrity is not a given in agent-aided software engineering.
Nothing was essentially broken, but every single change considers the behaviour when something goes wrong: a compromised dependency, a corrupt file, a crash at the worst millisecond, a URL that isn’t what it claims to be.
That’s what “hardening” actually means. Not paranoia. Just refusing to assume the happy path is the only path.
The app, the code, and all of these fixes are open on GitHub: github.com/mariolonghi/browser-tab-counter
Related links
- The project it hardens: Browser Tab Counter
- Download & source on GitHub: mariolonghi/browser-tab-counter