OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 42f2acd

security: gate read access to private notebooks (direct-fetch routes)
The notebook directory listing hides private notebooks from non-admins, but the direct-fetch read routes bypassed that filter entirely — anyone who knew or guessed a slug could read a private notebook's metadata, note list, and note contents: GET /api/notebooks/:notebook GET /api/notebooks/:notebook/notes GET /api/notebooks/:notebook/notes/:id Add enforceNotebookReadAccess(): for visibility==='private' notebooks it requires read authorization (authorizeRequest) and otherwise returns a 404 that does not leak existence. Public notebooks stay open. Encrypted-but-public notebooks are intentionally NOT gated — content is served as ciphertext and the metadata is needed for the unlock UI (privacy is keyed on visibility, matching the listing filter). Test: public readable anon; private → 404 for unauthorized (no leak); private readable by authorized; missing → 404. 4/4 pass. Note: the experimental multiwriter shared-notes read routes have the same gap and are tracked separately with the writer-admission hardening. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
42f2acdfdfdf94d0990f7e9e59ba6d220fe82418
by plankatron <fleeky@gmail.com> · 2026-06-23T20:28:45.000Z

Changed files (2) +79 -8

lib/routes/notebooks.js +34 -8
……@@ -67,6 +67,33 @@ async function resolveNoteOrFail (res, nm, notebook, identifier) {
6767 return note
6868}
6969
70/**
71 * Gate read access to a notebook. PRIVATE notebooks must not be readable by
72 * slug without read authorization — the directory listing already hides them,
73 * but direct fetch bypassed that filter (broken access control). On 404/denied
74 * sends the response and returns null; otherwise returns the notebook metadata.
75 *
76 * Encrypted-but-public notebooks are NOT gated here: their content is served as
77 * ciphertext and their metadata is needed for the unlock UI. Privacy is keyed
78 * on visibility, matching the listing's `visibility !== 'private'` filter.
79 */
80export async function enforceNotebookReadAccess (req, res, server, nm, slug) {
81 const meta = await nm.getNotebook(slug)
82 if (!meta) {
83 json(res, { error: 'Notebook not found', code: 'NOT_FOUND', status: 404 }, 404)
84 return null
85 }
86 if (meta.visibility === 'private') {
87 const { authorized } = await server.authManager.authorizeRequest(req, 'read', slug)
88 if (!authorized) {
89 // Don't leak existence of a private notebook — mirror the not-found shape.
90 json(res, { error: 'Notebook not found', code: 'NOT_FOUND', status: 404 }, 404)
91 return null
92 }
93 }
94 return meta
95}
96
7097function validateNotebookSlug (res, slug) {
7198 if (!NOTEBOOK_SLUG_RE.test(slug)) {
7299 json(res, { error: 'Invalid notebook slug format' }, 400)
……@@ -194,13 +221,10 @@ export function registerNotebookRoutes (router, server) {
194221 json(res, notebook, 201)
195222 })
196223
197 // Get notebook (open read)
224 // Get notebook (public read; private requires read auth)
198225 router.get('/api/notebooks/:notebook', async (req, res) => {
199 const notebook = await nm.getNotebook(req.params.notebook)
200 if (!notebook) {
201 json(res, { error: 'Notebook not found', code: 'NOT_FOUND', status: 404 }, 404)
202 return
203 }
226 const notebook = await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook)
227 if (!notebook) return
204228 json(res, notebook)
205229 })
206230
……@@ -234,8 +258,9 @@ export function registerNotebookRoutes (router, server) {
234258
235259 // ==================== NOTES ====================
236260
237 // List notes (open read)
261 // List notes (public read; private requires read auth)
238262 router.get('/api/notebooks/:notebook/notes', async (req, res) => {
263 if (!(await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook))) return
239264 const query = parseQuery(req.url)
240265 const limit = Math.min(Math.max(1, parseInt(query.limit) || 50), 500)
241266 const offset = Math.max(0, parseInt(query.offset) || 0)
……@@ -258,9 +283,10 @@ export function registerNotebookRoutes (router, server) {
258283 if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
259284 })
260285
261 // Get note (open read) — accepts slug or hex ID
286 // Get note (public read; private requires read auth) — accepts slug or hex ID
262287 router.get('/api/notebooks/:notebook/notes/:id', async (req, res) => {
263288 if (!validateNoteIdentifier(res, req.params.id)) return
289 if (!(await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook))) return
264290 const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
265291 if (!note) return
266292 json(res, note)
test/unit/notebook-read-access.test.js +45 -0
……@@ -0,0 +1,45 @@
1import test from 'brittle'
2import { enforceNotebookReadAccess } from '../../lib/routes/notebooks.js'
3
4function fakeRes () {
5 return { statusCode: 200, _body: null, setHeader () {}, end (b) { this._body = b } }
6}
7
8test('enforceNotebookReadAccess - public notebook is readable without auth', async (t) => {
9 const res = fakeRes()
10 const nm = { getNotebook: async () => ({ slug: 'pub', visibility: 'public' }) }
11 const server = { authManager: { authorizeRequest: async () => { throw new Error('public notebook must not require authorization') } } }
12
13 const meta = await enforceNotebookReadAccess({}, res, server, nm, 'pub')
14 t.ok(meta, 'returns metadata')
15 t.is(res.statusCode, 200, 'no error status')
16})
17
18test('enforceNotebookReadAccess - private notebook is hidden from unauthorized (404, no leak)', async (t) => {
19 const res = fakeRes()
20 const nm = { getNotebook: async () => ({ slug: 'sec', visibility: 'private' }) }
21 const server = { authManager: { authorizeRequest: async () => ({ authorized: false, auth: null, reason: 'Not authenticated' }) } }
22
23 const meta = await enforceNotebookReadAccess({}, res, server, nm, 'sec')
24 t.is(meta, null, 'access denied → null')
25 t.is(res.statusCode, 404, 'responds 404')
26 t.absent(res._body.toLowerCase().includes('private'), 'does not reveal the notebook is private')
27})
28
29test('enforceNotebookReadAccess - private notebook is readable by an authorized user', async (t) => {
30 const res = fakeRes()
31 const nm = { getNotebook: async () => ({ slug: 'sec', visibility: 'private' }) }
32 const server = { authManager: { authorizeRequest: async () => ({ authorized: true, auth: { userId: 'u' }, reason: null }) } }
33
34 const meta = await enforceNotebookReadAccess({}, res, server, nm, 'sec')
35 t.ok(meta, 'authorized → returns metadata')
36 t.is(meta.slug, 'sec')
37})
38
39test('enforceNotebookReadAccess - missing notebook → 404', async (t) => {
40 const res = fakeRes()
41 const nm = { getNotebook: async () => null }
42 const meta = await enforceNotebookReadAccess({}, res, {}, nm, 'nope')
43 t.is(meta, null)
44 t.is(res.statusCode, 404)
45})