A LinkedIn message pointed out that the CSS blur on my locked writeups was trivially bypassable with F12. Here's what I built to actually fix it.
Got a message on LinkedIn from Nathan a few days ago. He’d found inksec.io, looked at one of my locked writeups, opened developer tools, found the lock-blur class on the content div, removed it, and read the whole thing. He was nice about it — “just wanted to let you know” energy — but it was a fair callout.
He was right. The blur was always just CSS. The content was sitting in the DOM in plain HTML, and the password was hardcoded in the page source as a JavaScript string. Anyone who knew to look could have the answers in about ten seconds with no tools beyond a browser.
The locked writeups exist because platforms like CyberDefenders and BTLO ask you not to publish solutions to active challenges. The lock was my way of honouring that while still being able to show the work to recruiters. Enter a password, content decrypts, you can read the writeup. The intent was solid. The implementation wasn’t.
filter: blur(6px) and pointer-events: none in CSS is not security. It’s a vibe. Anyone who’s used developer tools for five minutes knows you can toggle CSS properties directly in the inspector. The content was always there — I was just making it slightly annoying to look at.
The fix I landed on was client-side AES encryption using CryptoJS. The idea is that the actual writeup content never appears in the page at all — only a ciphertext blob. When you enter the correct password the browser decrypts it and injects the HTML. Inspector shows gibberish instead of answers.
The stack:
encrypt_lab.py) that reads each lab’s rendered HTML after Jekyll builds, pulls the content div, encrypts it with OpenSSL-compatible AES-256-CBC, writes the ciphertext back into a data attribute, and clears the plaintextThe encryption uses EVP_BytesToKey with MD5 — the same key derivation CryptoJS defaults to — so CryptoJS.AES.decrypt(ciphertext, password) in the browser cleanly reverses what the Python script produced. Fresh random salt and IV on every build, so the ciphertext changes each deploy.
From the inspector now you see this:
<div id="enc-payload" data-ct="U2FsdGVkX1..." data-ct2="" style="display:none"></div>
<div id="lock-content" class="lab-content"></div>
Empty content div. Encrypted blob in a data attribute. That’s it.
While I was in there I added a secondary password field — tap: in the lab frontmatter. The idea is a per-lab temporary access pass: if someone needs to see a specific writeup and I want to give them access without handing out the global recruiter password, I add a random code to that lab, push, give them the code. To revoke it I remove the line and push again. The build script encrypts the content twice — once with the main password, once with the TAP — and stores both ciphertexts. The browser tries both. Same concept as Azure Temporary Access Pass, just for a static site.
The content is still technically on a public GitHub repo in the source markdown. Someone determined enough could read it there. This isn’t a vault — it’s a static site. But the threat model was always “random visitor opens F12 and reads the answers,” not “nation-state adversary with repo access.” For that threat model, this is now actually solid. The DOM inspection path is gone. That was Nathan’s callout, and it’s fixed.
Appreciate the message. Makes the site better.