OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 0b108da

security: gate shared-notes reads on notebook privacy
The experimental multiwriter shared-notes read routes were fully open: GET /api/notebooks/:notebook/shared-notes GET /api/notebooks/:notebook/shared-notes/:noteId/content so a private notebook that was also multiwriter-shared leaked its notes. Add denyPrivateSharedRead(): when the slug also has a regular notebook entry that is private, require read authorization (404 otherwise, no existence leak). Pure shared notebooks (no regular metadata, hence no visibility model) stay readable — the guard only denies known-private notebooks. The deeper writer-admission issue remains a tracked follow-up: the add-writer HTTP callback token cannot be bound to a writerKey (the inviter issues the token before the joiner generates its key), so admission must move to the Hyperswarm/blind-pairing control plane (P2P-not-HTTP) and gate on verified identity. Documented as experimental in the README. Test: pure-shared + public allowed; private blocks unauthorized (404); private allows authorized. 4/4 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0b108daa5548f38bad4555df36313e10a169080d
by plankatron <fleeky@gmail.com> · 2026-06-23T22:21:33.000Z

Changed files (2) +61 -2

lib/routes/multiwriter.js +22 -2
……@@ -17,6 +17,24 @@ import { json, error, readJson } from '../response.js'
1717import { UnauthorizedError, ForbiddenError } from '../errors.js'
1818import { requireAuth } from './auth-helpers.js'
1919
20/**
21 * Deny a shared-notes read when the slug also has a regular notebook entry that
22 * is private and the caller isn't authorized to read it. Pure shared notebooks
23 * (no regular metadata) stay readable — they have no visibility model. Sends a
24 * not-found response and returns true when access is denied.
25 */
26export async function denyPrivateSharedRead (req, res, server, slug) {
27 const meta = await server.noteManager.getNotebook(slug)
28 if (meta && meta.visibility === 'private') {
29 const { authorized } = await server.authManager.authorizeRequest(req, 'read', slug)
30 if (!authorized) {
31 json(res, { error: 'Notebook not shared', code: 'NOT_FOUND', status: 404 }, 404)
32 return true
33 }
34 }
35 return false
36}
37
2038/**
2139 * Register all multi-writer routes
2240 */
……@@ -156,9 +174,10 @@ export function registerMultiwriterRoutes (router, server) {
156174
157175 // ==================== SHARED NOTES ====================
158176
159 // List notes from shared Autobase (open read — matches notebook pattern)
177 // List notes from shared Autobase (public read; private notebooks gated)
160178 router.get('/api/notebooks/:notebook/shared-notes', async (req, res) => {
161179 const slug = req.params.notebook
180 if (await denyPrivateSharedRead(req, res, server, slug)) return
162181 const nb = server.multiwriter.getSharedNotebook(slug)
163182 if (!nb) {
164183 json(res, { error: 'Notebook not shared', code: 'NOT_FOUND', status: 404 }, 404)
……@@ -208,9 +227,10 @@ export function registerMultiwriterRoutes (router, server) {
208227 json(res, { ok: true })
209228 })
210229
211 // Get shared note content (open read)
230 // Get shared note content (public read; private notebooks gated)
212231 router.get('/api/notebooks/:notebook/shared-notes/:noteId/content', async (req, res) => {
213232 const slug = req.params.notebook
233 if (await denyPrivateSharedRead(req, res, server, slug)) return
214234 const nb = server.multiwriter.getSharedNotebook(slug)
215235 if (!nb) {
216236 json(res, { error: 'Notebook not shared', code: 'NOT_FOUND', status: 404 }, 404)
test/unit/shared-read-access.test.js +39 -0
……@@ -0,0 +1,39 @@
1import test from 'brittle'
2import { denyPrivateSharedRead } from '../../lib/routes/multiwriter.js'
3
4function fakeRes () {
5 return { statusCode: 200, _body: null, setHeader () {}, end (b) { this._body = b } }
6}
7
8function server ({ meta, authorized }) {
9 return {
10 noteManager: { getNotebook: async () => meta },
11 authManager: { authorizeRequest: async () => ({ authorized, auth: authorized ? { userId: 'u' } : null }) }
12 }
13}
14
15test('denyPrivateSharedRead - pure shared notebook (no metadata) is allowed', async (t) => {
16 const res = fakeRes()
17 const denied = await denyPrivateSharedRead({}, res, server({ meta: null }), 'x')
18 t.is(denied, false, 'not denied')
19 t.is(res.statusCode, 200, 'no response sent')
20})
21
22test('denyPrivateSharedRead - public notebook is allowed', async (t) => {
23 const res = fakeRes()
24 const denied = await denyPrivateSharedRead({}, res, server({ meta: { visibility: 'public' } }), 'x')
25 t.is(denied, false, 'not denied')
26})
27
28test('denyPrivateSharedRead - private notebook blocks unauthorized', async (t) => {
29 const res = fakeRes()
30 const denied = await denyPrivateSharedRead({}, res, server({ meta: { visibility: 'private' }, authorized: false }), 'sec')
31 t.is(denied, true, 'denied')
32 t.is(res.statusCode, 404, 'responds 404')
33})
34
35test('denyPrivateSharedRead - private notebook allows authorized reader', async (t) => {
36 const res = fakeRes()
37 const denied = await denyPrivateSharedRead({}, res, server({ meta: { visibility: 'private' }, authorized: true }), 'sec')
38 t.is(denied, false, 'authorized → allowed')
39})