A ShareNotes note can be locked with a PIN in two ways. Edit-only means anyone with the link can read, but only PIN holders can type; that is classroom mode, a board students watch while the teacher writes. View-and-edit means the PIN is needed to see the note at all.
Both depend on live sync, which runs over a WebSocket. We described the architecture in how real-time sync works: writes go over HTTP, and reads arrive over the socket. While writing that post we checked how each message on that socket was authorised. The answer, for two of them, was that it wasn't.
The bug class: a trusted socket, untrusted messages
A WebSocket is one long-lived connection carrying many messages. It is tempting to secure it the way you would secure a page load: decide once, when the connection opens, and trust everything that follows. The trouble is that "this client may connect" and "this client may do what this particular message asks" are different questions. Our socket carried two kinds of message from the client:
subscribe: "send me changes to this note".update: "here is new content for this note, pass it on".
Neither was checked against the note's PIN. On 3 October we found, and the same day fixed, three problems that were live in production:
- Anyone could push content into any note's live view. The server
relayed an
updatemessage to every subscriber without checking the PIN, so a hand-written client could inject text or markup into an edit-locked classroom board as students watched. The text was not saved, but everyone viewing saw it. - View-locked notes leaked over the socket. Subscribing needed no PIN, so a client that subscribed to a view-locked note received its full text every time it was edited.
- PIN guesses were unlimited, on the socket and over HTTP alike.
Rendering the injected markup made it worse. Note HTML was put into the page unsanitised, so injected markup could run script in the viewer's browser.
Check the PIN on every save, not on join
The fix for injection was to stop relaying client content at all. The client-sent
update was in fact redundant: every real edit already went to
PUT /api/notes/:slug, which checks the PIN, saves, and then broadcasts. So the
socket now ignores it:
case 'update':
// Deliberately ignored. Clients used to send note content here and it
// was relayed to every subscriber with no PIN check. Every real edit
// already reaches viewers: PUT /api/notes/:slug checks the PIN, saves,
// then calls broadcastUpdate() with the stored content.
break;
Everything that reaches a viewer now comes from the database, after a PIN check on that specific save. Nobody lost anything. Edits still appear live, because the broadcast after the save was already doing the work.
The general rule: a message that changes data needs its own authorisation, made when the message arrives, not when the connection opened. Here that check happens over HTTP, so the socket only ever broadcasts.
View-locked notes: subscribing needs the PIN too
For a view-locked note, receiving an update is reading the note. So a subscription to one now has to prove the PIN, the same as opening it in the browser. We also moved the check to the moment content is sent, rather than relying only on the subscribe:
clients.forEach((client) => {
if (!client.subscribedSlugs.has(slug)) return;
if (viewLocked && !client.viewAuthorized.has(slug)) return;
client.ws.send(JSON.stringify({ type: 'update', slug, content }));
});
Checking at send time matters because a subscription is just a name. A socket can subscribe to a name before any note with that name exists. If someone later creates a view-locked note there, a subscribe-time check would have waved that socket through while the name was still empty. Checking whether this connection proved the PIN, at the moment of every send, closes that gap whatever order things happen in.
Rate-limiting PIN guesses, and what carrier-grade NAT taught us
A 4-digit PIN has 10,000 possibilities. Without a limit, a script gets through them in minutes. We added a guard that counts wrong PINs per client IP address, per note. After 10 in 15 minutes, that address is refused for that note until the window passes.
Two design choices made it work:
- One count, shared by every place a PIN is checked: unlocking, saving, saving the title, and subscribing on the socket. Switching endpoint doesn't reset the count, so the socket is not a side door.
- Only wrong guesses count, and a correct PIN clears them. Someone who knows the PIN never notices the limit.
Then we tested it from a phone on mobile data. Carrier-grade NAT, where many subscribers share addresses and one subscriber's traffic moves between them, showed the same client arriving from 4 different addresses in 11 requests. A per-IP limit multiplies by the number of addresses you can reach, and some people can reach a lot. So there is now a second limit per note, across all addresses: 50 wrong guesses in 15 minutes and that note stops accepting PIN checks for the rest of the window.
That second limit has a cost, and we chose it knowingly: someone who wants to annoy you can lock PIN entry for one note for a quarter of an hour. Against that, a 6-digit PIN at 50 guesses per 15 minutes would take years to brute-force from any number of addresses. Once the limit is reached, a correct PIN doesn't reset it either. Otherwise an owner unlocking mid-attack would hand the attacker a fresh allowance.
Sanitising note HTML where it is rendered
The injection only mattered because of what the browser did with the text. Note HTML went into the page as-is, so a note containing an image with an error handler ran script on sharenotes.dev for everyone who opened it. Now every piece of note HTML is passed through DOMPurify before it is rendered: the viewer, the markdown preview, and the editor's initial content.
The defaults keep everything our formatting toolbar produces. We additionally forbid
<form> (a fake "enter your PIN" form is an obvious phishing trick) and
<style>, and new-tab links gain rel="noopener noreferrer". A
fixture test runs real toolbar output and pasted content through the sanitiser and requires
it to come out unchanged. Hostile payloads must come out neutralised.
Remembering an unlock without storing the PIN
One weakness stayed open after October, and we closed it this week. To spare people retyping a PIN, the page used to keep it in a cookie that page scripts could read. Any future script injection could have collected every PIN that browser had used.
Now the server remembers the unlock instead. After a correct PIN, it sets an HttpOnly cookie holding a signed token: valid for one note, for 7 days, and tied to that note's PIN hash. Page scripts cannot read it. The browser sends it automatically with requests, and with the opening handshake of the WebSocket. So the socket authorises a returning viewer of a view-locked note from that cookie, and never needs the PIN again. If you unlock a note after your socket is already open, the handshake had no cookie yet. The page then sends the PIN you just typed once, from memory, with the subscribe.
What we would tell someone building this
- Authorise messages, not connections. List every message type your socket accepts and write down what each one is allowed to do, for whom.
- Let the socket only broadcast what the database already holds, after the same checks an HTTP request would face. Then the socket has no authority of its own.
- Check at send time whenever "who may receive this" can change after subscribing.
- Share one guess counter across every place a secret is checked, and assume an attacker has more than one IP address.
- Treat stored HTML as hostile at render time, however it got there.
A PIN on ShareNotes is still an access check, not encryption. The server can read notes, and we say so on our page about PINs. Within that limit, every message on the socket is now checked against what that client has proved.