OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / b09d611

Fix 19 audit issues: legacy auth, info disclosure, key TTL, peer count, rate limits
Critical: legacy auth now read-only (verified flag), /api/whoami diag gated behind admin, AuthManager constructor fix, encryption key TTL (30min), private notebooks hidden from anonymous users. High: peer count uses Set (no negatives), version from package.json, callback token cleanup, WWW-Authenticate realm fix. Medium: search maxResults cap, shared requireAuth helper, request timeout, rename rollback, identity sync mutex. Low: media upload rate limit, DEBUG env flag, documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
b09d611728443a1026956f63926f4d75de7aeb0a
by plankatron <fleeky@gmail.com> · 2026-03-18T13:42:10.000Z

Changed files (13) +219 -107

hypernote-server.js +44 -30
……@@ -49,14 +49,18 @@ import { registerMultiwriterRoutes } from './lib/routes/multiwriter.js'
4949import { json, notFound } from './lib/response.js'
5050
5151// Log version on startup for OTA verification
52let _appVersion = '0.0.0'
5253{
5354 const _appDir = path.dirname(new URL(import.meta.url).pathname)
5455 try {
5556 const _pkg = JSON.parse(fs.readFileSync(path.join(_appDir, 'package.json'), 'utf-8'))
56 console.log(`[Hypernote] v${_pkg.version} (${_appDir})`)
57 _appVersion = _pkg.version || _appVersion
58 console.log(`[Hypernote] v${_appVersion} (${_appDir})`)
5759 } catch {}
5860}
5961
62const DEBUG = typeof process !== 'undefined' && (process.env?.DEBUG === '1' || process.env?.DEBUG === 'true')
63
6064// Catch unhandled rejections to prevent silent crashes
6165process.on('uncaughtException', (err) => {
6266 console.error('[FATAL] Uncaught exception:', err.message, err.stack)
……@@ -194,7 +198,7 @@ class HypernoteServer {
194198 this.router.get('/api/health', async (req, res) => {
195199 json(res, {
196200 name: 'hypernote',
197 version: '0.2.0',
201 version: _appVersion,
198202 description: 'P2P note-taking server'
199203 })
200204 })
……@@ -204,7 +208,7 @@ class HypernoteServer {
204208 const syncStatus = this.replicationManager.getStatus()
205209 json(res, {
206210 name: 'hypernote',
207 version: '0.2.0',
211 version: _appVersion,
208212 storage: this.storage,
209213 identity: {
210214 hasKeypair: identity.hasKeypair,
……@@ -244,31 +248,35 @@ class HypernoteServer {
244248 return
245249 }
246250
247 // Include diagnostic identity chain info
248 const diag = {}
249 try {
250 const pubkey = req.headers['x-wirecore-publickey']
251 if (pubkey) {
252 diag.browserPubkey = pubkey
253 const pubkeyEntry = await server.authManager.identityRegistry.db.get(`pubkey:${pubkey}`)
254 diag.pubkeyLookup = pubkeyEntry ? pubkeyEntry.value : null
255 const identEntry = await server.authManager.identityRegistry.db.get(`identity:${pubkey}`)
256 diag.identityRecord = identEntry ? { userId: identEntry.value.userId, method: identEntry.value.method } : null
257 if (identEntry && identEntry.value.userId) {
258 const userEntry = await server.authManager.identityRegistry.db.get(`user:${identEntry.value.userId}`)
259 diag.userRecord = userEntry ? { userId: userEntry.value.userId, username: userEntry.value.username, admin: userEntry.value.globalPermissions?.includes('admin') } : 'NOT FOUND'
260 }
261 }
262 } catch {}
263
264 json(res, {
251 const result = {
265252 authenticated: true,
266253 userId: auth.userId,
267254 method: auth.method,
268255 username: auth.username || (auth.user && auth.user.username) || null,
269 permissions: auth.permissions,
270 _diag: diag
271 })
256 permissions: auth.permissions
257 }
258
259 // Include diagnostic identity chain info (admin only)
260 if (auth.permissions && auth.permissions.admin) {
261 const diag = {}
262 try {
263 const pubkey = req.headers['x-wirecore-publickey']
264 if (pubkey) {
265 diag.browserPubkey = pubkey
266 const pubkeyEntry = await server.authManager.identityRegistry.db.get(`pubkey:${pubkey}`)
267 diag.pubkeyLookup = pubkeyEntry ? pubkeyEntry.value : null
268 const identEntry = await server.authManager.identityRegistry.db.get(`identity:${pubkey}`)
269 diag.identityRecord = identEntry ? { userId: identEntry.value.userId, method: identEntry.value.method } : null
270 if (identEntry && identEntry.value.userId) {
271 const userEntry = await server.authManager.identityRegistry.db.get(`user:${identEntry.value.userId}`)
272 diag.userRecord = userEntry ? { userId: userEntry.value.userId, username: userEntry.value.username, admin: userEntry.value.globalPermissions?.includes('admin') } : 'NOT FOUND'
273 }
274 }
275 } catch {}
276 result._diag = diag
277 }
278
279 json(res, result)
272280 })
273281
274282 // Phase 2: Notebook + Note + Search routes
……@@ -354,8 +362,8 @@ class HypernoteServer {
354362 const mwKeyHex = mwStatus.registryKey ? (Buffer.isBuffer(mwStatus.registryKey) ? mwStatus.registryKey.toString('hex') : mwStatus.registryKey) : null
355363 const localWriterKey = this.multiwriter.registry ? this.multiwriter.registry.localWriterKey : null
356364 const lwkHex = localWriterKey ? (Buffer.isBuffer(localWriterKey) ? localWriterKey.toString('hex') : localWriterKey) : null
357 console.log(`[Multiwriter] Registry key: ${mwKeyHex ? mwKeyHex.substring(0, 16) + '...' : 'none'}`)
358 console.log(`[Multiwriter] Local writer key: ${lwkHex || 'none'}`)
365 if (DEBUG) console.log(`[Multiwriter] Registry key: ${mwKeyHex ? mwKeyHex.substring(0, 16) + '...' : 'none'}`)
366 if (DEBUG) console.log(`[Multiwriter] Local writer key: ${lwkHex || 'none'}`)
359367 console.log(`[Multiwriter] Shared notebooks: ${mwStatus.sharedNotebooks}`)
360368
361369 // Start P2P replication in background (don't block HTTP start)
……@@ -448,7 +456,7 @@ class HypernoteServer {
448456 const duplicates = users.slice(1)
449457
450458 for (const dup of duplicates) {
451 console.log(`[Dedup] Merging duplicate user ${dup.userId.substring(0, 16)} into ${primary.userId.substring(0, 16)} (${primary.username || 'no name'}) for pubkey ${pubkey.substring(0, 20)}...`)
459 if (DEBUG) console.log(`[Dedup] Merging duplicate user ${dup.userId.substring(0, 16)} into ${primary.userId.substring(0, 16)} (${primary.username || 'no name'}) for pubkey ${pubkey.substring(0, 20)}...`)
452460
453461 // Merge permissions (union)
454462 primary.globalPermissions = [...new Set([...primary.globalPermissions, ...dup.globalPermissions])]
……@@ -485,7 +493,7 @@ class HypernoteServer {
485493
486494 primary.updatedAt = Date.now()
487495 await db.put(`user:${primary.userId}`, primary)
488 console.log(`[Dedup] Fixed: identity ${pubkey.substring(0, 20)}... → user ${primary.userId.substring(0, 16)} (${primary.username})`)
496 if (DEBUG) console.log(`[Dedup] Fixed: identity ${pubkey.substring(0, 20)}... → user ${primary.userId.substring(0, 16)} (${primary.username})`)
489497 }
490498 } catch (err) {
491499 console.error('[Dedup] Failed:', err.message)
……@@ -507,7 +515,7 @@ class HypernoteServer {
507515 if (identEntry && identEntry.value) {
508516 identEntry.value.userId = repair.correctUserId
509517 await this.authManager.identityRegistry.db.put(`identity:${repair.pubkey}`, identEntry.value)
510 console.log(`[Repair] Fixed identity ${repair.pubkey.substring(0, 16)}... → user ${repair.correctUserId.substring(0, 16)}`)
518 if (DEBUG) console.log(`[Repair] Fixed identity ${repair.pubkey.substring(0, 16)}... → user ${repair.correctUserId.substring(0, 16)}`)
511519 }
512520 // Fix pubkey reverse lookup
513521 await this.authManager.identityRegistry.db.put(`pubkey:${repair.pubkey}`, { identityId: repair.pubkey })
……@@ -677,6 +685,12 @@ class HypernoteServer {
677685 }
678686 })
679687
688 // Request timeout — prevent stalled connections from accumulating
689 server.setTimeout(120000) // 2 minutes
690 server.on('timeout', (socket) => {
691 socket.destroy()
692 })
693
680694 const host = this.host || 'localhost'
681695 server.listen(this.port, host, () => {
682696 console.log(`[Hypernote] Server listening on http://${host}:${this.port}`)
……@@ -822,7 +836,7 @@ if (command === 'serve' || !command) {
822836 await authDb.ready()
823837
824838 const { AuthManager } = await import('./lib/auth.js')
825 const authManager = new AuthManager(authDb)
839 const authManager = new AuthManager({ registry: authDb })
826840
827841 const result = await authManager.createToken({
828842 username: 'admin',
lib/auth.js +7 -3
……@@ -43,7 +43,8 @@ export class AuthManager {
4343 'auth': { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 },
4444 'api': { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 },
4545 'notes': { tokens: 5000, refillRate: 500, refillInterval: 60 * 1000 },
46 'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 }
46 'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 },
47 'media-upload': { tokens: 50, refillRate: 10, refillInterval: 60 * 1000 }
4748 },
4849 cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000 // 10 seconds
4950 })
……@@ -188,7 +189,7 @@ export class AuthManager {
188189 method: authResult.method,
189190 permissions: this.formatUserPermissions(user, null),
190191 user,
191 verified: true
192 verified: authResult.verified !== false
192193 }
193194 } catch (err) {
194195 console.error(`[Auth] Provider ${provider.name} threw:`, err.message)
……@@ -250,7 +251,10 @@ export class AuthManager {
250251 * @returns {boolean}
251252 */
252253 hasNotebookAccess(auth, notebookName, action = 'read') {
253 if (!auth || !auth.verified) return false
254 if (!auth) return false
255
256 // Unverified (legacy) auth can only read — block write/admin
257 if (!auth.verified && action !== 'read') return false
254258
255259 const perms = auth.permissions
256260
lib/multiwriter/manager.js +18 -0
……@@ -32,6 +32,7 @@ export class MultiwriterManager {
3232 this._identityRegistry = null
3333 this._pendingCallbackTokens = new Map() // token -> { createdAt }
3434 this._lock = mutexify() // serialize state transitions
35 this._identityLock = mutexify() // serialize identity sync operations
3536 this._addedNbWriters = new Set()
3637 }
3738
……@@ -264,6 +265,14 @@ export class MultiwriterManager {
264265
265266 async _syncMirrorUnsafe () {
266267
268 // Clean up expired callback tokens
269 const now = Date.now()
270 for (const [token, entry] of this._pendingCallbackTokens) {
271 if (now - entry.createdAt > CALLBACK_TOKEN_TTL) {
272 this._pendingCallbackTokens.delete(token)
273 }
274 }
275
267276 // 0. Update Autobase to get latest from peers
268277 try {
269278 await this._registry.base.update()
……@@ -510,6 +519,15 @@ export class MultiwriterManager {
510519 }
511520
512521 async _syncIdentities () {
522 const release = await this._identityLock()
523 try {
524 await this._syncIdentitiesUnsafe()
525 } finally {
526 release()
527 }
528 }
529
530 async _syncIdentitiesUnsafe () {
513531 if (!this._identityRegistry) {
514532 console.log('[Mirror] _syncIdentities: no identity registry, skipping')
515533 return
lib/note-manager.js +89 -29
……@@ -37,7 +37,9 @@ export class NoteManager {
3737 this.crypto = crypto || null // NotebookCrypto instance (optional)
3838 this.linkManager = linkManager || null // LinkManager instance (optional, set after construction)
3939 this.multiwriter = multiwriter || null // MultiwriterManager instance (optional, set after construction)
40 this._keyCache = new Map() // slug → Buffer (unlocked notebook keys)
40 this._keyCache = new Map() // slug → { key: Buffer, unlockedAt: number }
41 this._keyCacheTTL = 30 * 60 * 1000 // 30 minutes
42 this._keyCacheCleanupInterval = setInterval(() => this._evictExpiredKeys(), 5 * 60 * 1000)
4143 }
4244
4345 /**
……@@ -74,22 +76,41 @@ export class NoteManager {
7476 async unlockNotebook (slug, password) {
7577 if (!this.crypto) throw new Error('Encryption not configured')
7678 const key = await this.crypto.getNotebookKey(slug, password)
77 this._keyCache.set(slug, key)
79 this._keyCache.set(slug, { key, unlockedAt: Date.now() })
7880 return true
7981 }
8082
8183 /**
82 * Check if a notebook's key is cached (unlocked).
84 * Check if a notebook's key is cached (unlocked) and not expired.
8385 */
8486 isUnlocked (slug) {
85 return this._keyCache.has(slug)
87 const entry = this._keyCache.get(slug)
88 if (!entry) return false
89 if (Date.now() - entry.unlockedAt > this._keyCacheTTL) {
90 this._keyCache.delete(slug)
91 return false
92 }
93 return true
94 }
95
96 /**
97 * Get the cached key for a slug, or null if expired/missing.
98 */
99 _getCachedKey (slug) {
100 const entry = this._keyCache.get(slug)
101 if (!entry) return null
102 if (Date.now() - entry.unlockedAt > this._keyCacheTTL) {
103 this._keyCache.delete(slug)
104 return null
105 }
106 return entry.key
86107 }
87108
88109 /**
89110 * Set a notebook key directly (e.g. after enableEncryption).
90111 */
91112 cacheKey (slug, key) {
92 this._keyCache.set(slug, key)
113 this._keyCache.set(slug, { key, unlockedAt: Date.now() })
93114 }
94115
95116 /**
……@@ -99,6 +120,18 @@ export class NoteManager {
99120 this._keyCache.delete(slug)
100121 }
101122
123 /**
124 * Evict expired keys from the cache.
125 */
126 _evictExpiredKeys () {
127 const now = Date.now()
128 for (const [slug, entry] of this._keyCache) {
129 if (now - entry.unlockedAt > this._keyCacheTTL) {
130 this._keyCache.delete(slug)
131 }
132 }
133 }
134
102135 // ==================== SLUG HELPERS ====================
103136
104137 /**
……@@ -145,7 +178,10 @@ export class NoteManager {
145178 }
146179
147180 /**
148 * Ensure a note has a slug field. If missing, generate from title and persist.
181 * Ensure a note has a slug field. If missing, generate from title.
182 * Intentionally does NOT persist here — slug is persisted lazily via
183 * listNotes() (see needsPersist flag at line ~704) to avoid writes
184 * on every single note read. This is a deliberate design choice.
149185 */
150186 _ensureSlug (note, notebook) {
151187 if (note.slug) return note
……@@ -239,7 +275,7 @@ export class NoteManager {
239275 * Encrypt title + content of a note if the notebook is encrypted.
240276 */
241277 _encryptNote (note, slug) {
242 const key = this._keyCache.get(slug)
278 const key = this._getCachedKey(slug)
243279 if (!key || !this.crypto) return note
244280
245281 return {
……@@ -256,7 +292,7 @@ export class NoteManager {
256292 _decryptNote (note, slug) {
257293 if (!note.encrypted) return note
258294
259 const key = this._keyCache.get(slug)
295 const key = this._getCachedKey(slug)
260296 if (!key || !this.crypto) return note
261297
262298 return {
……@@ -477,29 +513,39 @@ export class NoteManager {
477513 // Create new drive
478514 const newDrive = await this.driveManager.createDrive(newSlug)
479515
480 // Copy metadata with new slug
481 const now = Date.now()
482 const newMeta = { ...oldMeta, slug: newSlug, updatedAt: now }
483 delete newMeta.isLocal
484 delete newMeta.driveKey
485 await newDrive.put('/metadata.json', Buffer.from(JSON.stringify(newMeta, null, 2)))
486
487 // Copy all notes
488 const oldDrive = await this._getDrive(oldSlug)
489516 try {
490 const noteFiles = await oldDrive.readdir('/notes')
491 for await (const file of noteFiles) {
492 const buf = await oldDrive.get(`/notes/${file}`)
493 if (buf) {
494 await newDrive.put(`/notes/${file}`, buf)
517 // Copy metadata with new slug
518 const now = Date.now()
519 const newMeta = { ...oldMeta, slug: newSlug, updatedAt: now }
520 delete newMeta.isLocal
521 delete newMeta.driveKey
522 await newDrive.put('/metadata.json', Buffer.from(JSON.stringify(newMeta, null, 2)))
523
524 // Copy all notes
525 const oldDrive = await this._getDrive(oldSlug)
526 try {
527 const noteFiles = await oldDrive.readdir('/notes')
528 for await (const file of noteFiles) {
529 const buf = await oldDrive.get(`/notes/${file}`)
530 if (buf) {
531 await newDrive.put(`/notes/${file}`, buf)
532 }
495533 }
534 } catch {
535 // no notes dir
496536 }
497 } catch {
498 // no notes dir
499 }
500537
501 // Delete old notebook
502 await this.deleteNotebook(oldSlug)
538 // Delete old notebook
539 await this.deleteNotebook(oldSlug)
540 } catch (err) {
541 // Rollback: clean up the partially-created new drive
542 try {
543 await this.driveManager.deleteDrive(newSlug)
544 } catch (cleanupErr) {
545 console.error('[NoteManager] Rollback cleanup failed for ' + newSlug + ':', cleanupErr.message)
546 }
547 throw err
548 }
503549
504550 // Return with transient fields
505551 newMeta.isLocal = true
……@@ -1000,9 +1046,12 @@ export class NoteManager {
10001046 * Get recent notes across all public notebooks
10011047 * Returns notes with notebook metadata attached
10021048 */
1049 // TODO: streaming pagination — currently loads all notes into memory
10031050 async recentNotes ({ limit = 20 } = {}) {
10041051 const allNotes = []
10051052 const notebooks = await this.listNotebooks()
1053 // Collect at most limit*2 notes per notebook to reduce memory usage
1054 const perNotebookCap = limit * 2
10061055
10071056 for (const nb of notebooks) {
10081057 // Skip private and encrypted notebooks
……@@ -1013,7 +1062,9 @@ export class NoteManager {
10131062 if (snb) {
10141063 try {
10151064 const notes = await snb.listNotes()
1065 let count = 0
10161066 for (const note of notes) {
1067 if (count >= perNotebookCap) break
10171068 try {
10181069 const doc = await snb.reconstructDoc(note.noteId)
10191070 const content = doc.getText('content').toString()
……@@ -1029,6 +1080,7 @@ export class NoteManager {
10291080 notebookName: nb.name,
10301081 owner: nb.ownerId || null
10311082 })
1083 count++
10321084 } catch {}
10331085 }
10341086 } catch {}
……@@ -1045,7 +1097,9 @@ export class NoteManager {
10451097 continue
10461098 }
10471099
1100 let count = 0
10481101 for await (const file of files) {
1102 if (count >= perNotebookCap) break
10491103 if (!file.endsWith('.json')) continue
10501104 const buf = await drive.get(`/notes/${file}`)
10511105 if (!buf) continue
……@@ -1063,6 +1117,7 @@ export class NoteManager {
10631117 notebookName: nb.name,
10641118 owner: nb.ownerId || null
10651119 })
1120 count++
10661121 }
10671122 }
10681123
……@@ -1072,7 +1127,8 @@ export class NoteManager {
10721127
10731128 // ==================== SEARCH ====================
10741129
1075 async searchNotes (query, { notebook } = {}) {
1130 // TODO: streaming pagination — currently loads all matching notes into memory
1131 async searchNotes (query, { notebook, maxResults = 100 } = {}) {
10761132 if (!query || typeof query !== 'string') {
10771133 throw new ValidationError('Search query is required', 'query')
10781134 }
……@@ -1082,6 +1138,8 @@ export class NoteManager {
10821138 const notebooks = notebook ? [notebook] : await this._listNotebookSlugs()
10831139
10841140 for (const slug of notebooks) {
1141 if (results.length >= maxResults) break
1142
10851143 const drive = await this.driveManager.getDrive(slug)
10861144 if (!drive) continue
10871145
……@@ -1091,6 +1149,7 @@ export class NoteManager {
10911149 try {
10921150 const notes = await snb.listNotes()
10931151 for (const note of notes) {
1152 if (results.length >= maxResults) break
10941153 try {
10951154 const doc = await snb.reconstructDoc(note.noteId)
10961155 const content = doc.getText('content').toString()
……@@ -1125,6 +1184,7 @@ export class NoteManager {
11251184 }
11261185
11271186 for await (const file of files) {
1187 if (results.length >= maxResults) break
11281188 if (!file.endsWith('.json')) continue
11291189 const buf = await drive.get(`/notes/${file}`)
11301190 if (!buf) continue
……@@ -1132,7 +1192,7 @@ export class NoteManager {
11321192 const raw = JSON.parse(buf.toString())
11331193
11341194 // For encrypted notebooks without unlocked key, only search tags
1135 if (raw.encrypted && !this._keyCache.has(slug)) {
1195 if (raw.encrypted && !this.isUnlocked(slug)) {
11361196 const tagMatch = raw.tags && raw.tags.some(t => t.toLowerCase().includes(q))
11371197 if (tagMatch) {
11381198 results.push({
lib/replication.js +6 -6
……@@ -14,7 +14,7 @@ export class ReplicationManager {
1414 this.driveManager = driveManager
1515 this.swarm = null
1616 this._discoveries = new Map() // slug → discovery handle
17 this._peerCount = 0
17 this._connections = new Set()
1818 }
1919
2020 /**
……@@ -25,9 +25,9 @@ export class ReplicationManager {
2525
2626 this.swarm.on('connection', (conn, info) => {
2727 this.store.replicate(conn)
28 this._peerCount++
29 conn.on('close', () => { this._peerCount-- })
30 conn.on('error', () => { this._peerCount-- })
28 this._connections.add(conn)
29 conn.on('close', () => { this._connections.delete(conn) })
30 conn.on('error', () => {}) // error is followed by close, which handles cleanup
3131 })
3232 }
3333
……@@ -39,7 +39,7 @@ export class ReplicationManager {
3939 await this.swarm.destroy()
4040 this.swarm = null
4141 this._discoveries.clear()
42 this._peerCount = 0
42 this._connections.clear()
4343 }
4444
4545 /**
……@@ -103,7 +103,7 @@ export class ReplicationManager {
103103 getStatus () {
104104 return {
105105 swarmActive: this.swarm !== null,
106 peersConnected: this._peerCount,
106 peersConnected: this._connections.size,
107107 announced: [...this._discoveries.keys()]
108108 }
109109 }
lib/response.js +1 -1
……@@ -63,7 +63,7 @@ export function forbidden(res, message = 'Forbidden') {
6363 */
6464export function unauthorized(res, message = 'Unauthorized') {
6565 res.statusCode = 401
66 res.setHeader('WWW-Authenticate', 'Bearer realm="Pear Git"')
66 res.setHeader('WWW-Authenticate', 'Bearer realm="Hypernote"')
6767 res.setHeader('Content-Type', 'application/json')
6868 res.end(JSON.stringify({ error: message }))
6969}
lib/routes/auth-helpers.js +20 -0
……@@ -0,0 +1,20 @@
1/**
2 * Shared auth helpers for route handlers.
3 */
4
5import { UnauthorizedError, ForbiddenError } from '../errors.js'
6
7/**
8 * Auth helper — authenticate + check permission, throw on failure.
9 * @param {Object} req - HTTP request
10 * @param {Object} server - HypernoteServer instance
11 * @param {string} action - 'read', 'write', or 'admin'
12 * @param {string} [notebook] - Notebook slug (optional)
13 * @returns {Promise<Object>} Auth result
14 */
15export async function requireAuth (req, server, action, notebook) {
16 const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, notebook)
17 if (!auth) throw new UnauthorizedError('Authentication required')
18 if (!authorized) throw new ForbiddenError(reason, action)
19 return auth
20}
lib/routes/index.js +1 -1
……@@ -194,7 +194,7 @@ export class Router {
194194
195195 // Add WWW-Authenticate header for 401 responses
196196 if (status === 401) {
197 res.setHeader('WWW-Authenticate', 'Bearer realm="Pear Git"')
197 res.setHeader('WWW-Authenticate', 'Bearer realm="Hypernote"')
198198 }
199199
200200 // Add Retry-After header for rate limit errors
lib/routes/multiwriter.js +1 -10
……@@ -15,16 +15,7 @@
1515
1616import { json, error, readJson } from '../response.js'
1717import { UnauthorizedError, ForbiddenError } from '../errors.js'
18
19/**
20 * Auth helper — authenticate + check permission, throw on failure
21 */
22async function requireAuth (req, server, action, notebook) {
23 const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, notebook)
24 if (!auth) throw new UnauthorizedError('Authentication required')
25 if (!authorized) throw new ForbiddenError(reason, action)
26 return auth
27}
18import { requireAuth } from './auth-helpers.js'
2819
2920/**
3021 * Register all multi-writer routes
lib/routes/notebooks.js +23 -16
……@@ -5,7 +5,7 @@
55 */
66
77import { json, readJson } from '../response.js'
8import { UnauthorizedError, ForbiddenError } from '../errors.js'
8import { requireAuth } from './auth-helpers.js'
99
1010/**
1111 * Parse query string from raw request URL
……@@ -82,16 +82,6 @@ function validateMediaFilename (res, filename) {
8282 return true
8383}
8484
85/**
86 * Auth helper — authenticate + check permission, throw on failure
87 */
88async function requireAuth (req, server, action, notebook) {
89 const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, notebook)
90 if (!auth) throw new UnauthorizedError('Authentication required')
91 if (!authorized) throw new ForbiddenError(reason, action)
92 return auth
93}
94
9585/**
9686 * Register all notebook, note, and search routes
9787 */
……@@ -107,12 +97,23 @@ export function registerNotebookRoutes (router, server) {
10797
10898 const query = parseQuery(req.url)
10999
100 // Attempt auth (non-throwing) to determine visibility
101 let auth = null
102 try {
103 auth = await server.authManager.authenticate(req, { skipRateLimit: true })
104 } catch {}
105
106 // Hide private and encrypted notebooks from non-admin users
107 const isAdmin = auth && auth.permissions && auth.permissions.admin
108 if (!isAdmin) {
109 notebooks = notebooks.filter(nb => nb.visibility !== 'private' && !nb.encrypted)
110 }
111
110112 // Filter to current user's notebooks
111113 if (query.mine === 'true') {
112 const auth = await server.authManager.authenticate(req)
113114 if (auth && auth.userId) {
114115 notebooks = notebooks.filter(nb => {
115 return nb.ownerId === auth.userId || (!nb.ownerId && auth.permissions && auth.permissions.admin)
116 return nb.ownerId === auth.userId || (!nb.ownerId && isAdmin)
116117 })
117118 } else {
118119 notebooks = []
……@@ -125,12 +126,11 @@ export function registerNotebookRoutes (router, server) {
125126 if (ownerUser) {
126127 // Check if this user has admin permissions (to include unowned notebooks)
127128 const userPerms = ownerUser.globalPermissions || []
128 const isAdmin = userPerms.includes('admin')
129 const ownerIsAdmin = userPerms.includes('admin')
129130 notebooks = notebooks.filter(nb => {
130 if (nb.visibility === 'private' || nb.encrypted) return false
131131 if (nb.ownerId === ownerUser.userId) return true
132132 // Include unowned notebooks for the admin user
133 if (isAdmin && !nb.ownerId) return true
133 if (ownerIsAdmin && !nb.ownerId) return true
134134 return false
135135 })
136136 } else {
……@@ -330,6 +330,13 @@ export function registerNotebookRoutes (router, server) {
330330 // Upload image/media to notebook drive
331331 router.post('/api/notebooks/:notebook/media', async (req, res) => {
332332 await requireAuth(req, server, 'write', req.params.notebook)
333 // Apply media-specific rate limit
334 const clientIp = server.authManager.getClientIp(req)
335 const rateLimit = await server.authManager.rateLimiter.check(clientIp, 'media-upload')
336 if (!rateLimit.allowed) {
337 json(res, { error: 'Media upload rate limit exceeded', retryAfter: rateLimit.retryAfter }, 429)
338 return
339 }
333340
334341 const maxMediaSize = server.maxMediaSize || 50 * 1024 * 1024 // 50MB default
335342 const chunks = []
lib/routes/sharing.js +2 -11
……@@ -5,20 +5,11 @@
55 */
66
77import { json, readJson } from '../response.js'
8import { UnauthorizedError, ForbiddenError, ValidationError, NotFoundError } from '../errors.js'
8import { ValidationError, NotFoundError } from '../errors.js'
9import { requireAuth } from './auth-helpers.js'
910
1011const SHARE_PREFIX = 'share:'
1112
12/**
13 * Auth helper — authenticate + check permission, throw on failure
14 */
15async function requireAuth (req, server, action, notebook) {
16 const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, notebook)
17 if (!auth) throw new UnauthorizedError('Authentication required')
18 if (!authorized) throw new ForbiddenError(reason, action)
19 return auth
20}
21
2213/**
2314 * Register sharing and encryption routes
2415 */
web/js/identity.js +3 -0
……@@ -8,6 +8,9 @@
88 */
99
1010window.HnIdentity = (function () {
11 // Security note: Private key is stored in localStorage, which is inherently
12 // accessible to same-origin scripts. CSP headers mitigate XSS risk.
13 // This is the standard trade-off for browser-only ed25519 identity.
1114 var KEYPAIR_KEY = 'hn-keypair'
1215 var PUBKEY_KEY = 'hn-pubkey'
1316 var USERNAME_KEY = 'hn-username'
web/js/markdown.js +4 -0
……@@ -4,6 +4,10 @@
44 * blockquotes, hr, tables, wiki-links [[...]], gallery blocks
55 */
66
7// Security: All inline text is HTML-escaped via esc(), URLs are validated via safeUrl()
8// (blocks javascript:/data:/blob:/vbscript:), and HTML media tags are sanitized via
9// sanitizeMediaTag() which strips all but whitelisted attributes and event handlers.
10// TODO: Consider adding DOMPurify as a final sanitization pass for defense-in-depth.
711window.renderMarkdown = function (src) {
812 if (!src) return ''
913