OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / ad49dd7

Security audit: fix XSS, CORS, input validation, race conditions, resource leaks
- Fix 3 XSS vectors in markdown renderer (javascript: links, video/audio event handlers, data: URLs) - CORS: whitelist origins instead of echoing any origin with credentials - Remove /api/debug/identity-chain endpoint (leaked identity registry) - Add input validation: noteId, media filename, notebook slug, query parser bounds - Add CSP header to all HTML responses (script-src 'self') - Remove bearer token from URL query params (header-only now) - Fix challenge TOCTOU race with per-challenge async locks, reduce TTL to 60s - Add mutexify locks to multiwriter joinWithInvite/syncMirror - Add 30-min TTL to callback tokens (was unbounded) - Fix Hyperblobs resource leak: cache remote instances instead of creating per-call - readJson now throws 400 on malformed JSON (was 500) - Server error handler respects err.statusCode - Clean up 20+ sensitive debug logs leaking userId/pubkey/admin status - Fix mirror sync timer cleanup on close Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ad49dd7a2f5838edaafc7864aaa98d7711c6ebe8
by plankatron <fleeky@gmail.com> · 2026-03-17T14:16:23.000Z

Changed files (12) +288 -222

hypernote-server.js +18 -39
……@@ -271,40 +271,6 @@ class HypernoteServer {
271271 })
272272 })
273273
274 // Diagnostic: dump identity chain for a pubkey (localhost only)
275 this.router.get('/api/debug/identity-chain', async (req, res, server) => {
276 const ip = req.socket?.remoteAddress || ''
277 if (!ip.includes('127.0.0.1') && !ip.includes('::1') && !ip.includes('::ffff:127.0.0.1')) {
278 json(res, { error: 'localhost only' }, 403)
279 return
280 }
281 const pubkey = req.url.includes('pubkey=') ? decodeURIComponent(req.url.split('pubkey=')[1].split('&')[0]) : null
282 const result = { pubkey }
283 if (pubkey) {
284 const pe = await server.authManager.identityRegistry.db.get(`pubkey:${pubkey}`)
285 result.pubkeyEntry = pe ? pe.value : null
286 const ie = await server.authManager.identityRegistry.db.get(`identity:${pubkey}`)
287 result.identityEntry = ie ? ie.value : null
288 if (ie && ie.value && ie.value.userId) {
289 const ue = await server.authManager.identityRegistry.db.get(`user:${ie.value.userId}`)
290 result.userEntry = ue ? ue.value : null
291 }
292 if (server.authManager.sharedRegistry) {
293 try {
294 const su = await server.authManager.sharedRegistry.getSharedUserByPublicKey(pubkey)
295 result.sharedRegistryUser = su || null
296 } catch {}
297 }
298 }
299 const users = []
300 for await (const entry of server.authManager.identityRegistry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
301 const u = entry.value
302 users.push({ userId: u.userId, username: u.username, admin: u.globalPermissions?.includes('admin'), identities: u.identities, mergedInto: u.mergedInto })
303 }
304 result.allUsers = users
305 json(res, result)
306 })
307
308274 // Phase 2: Notebook + Note + Search routes
309275 registerNotebookRoutes(this.router, this)
310276
……@@ -666,10 +632,22 @@ class HypernoteServer {
666632 async start() {
667633 await this.ready()
668634
635 // Build CORS origin whitelist from server config
636 const allowedOrigins = new Set([
637 `http://localhost:${this.port}`,
638 `http://127.0.0.1:${this.port}`,
639 `http://[::1]:${this.port}`
640 ])
641 // In network mode, also allow LAN hostname
642 if (this.host === '0.0.0.0') {
643 const lanAddr = HypernoteServer._getLanAddress()
644 if (lanAddr) allowedOrigins.add(`http://${lanAddr}:${this.port}`)
645 }
646
669647 const server = http.createServer(async (req, res) => {
670 // CORS — allow hyper:// and other origins to call the API
648 // CORS — whitelist-based origin checking
671649 const origin = req.headers.origin || ''
672 if (origin) {
650 if (origin && (allowedOrigins.has(origin) || origin.startsWith('hyper://'))) {
673651 res.setHeader('Access-Control-Allow-Origin', origin)
674652 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
675653 res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Wirecore-PublicKey, X-Wirecore-Signature, X-Wirecore-Challenge')
……@@ -689,11 +667,12 @@ class HypernoteServer {
689667 notFound(res, 'Route not found')
690668 }
691669 } catch (err) {
692 console.error('[Hypernote] Request error:', err)
693670 if (!res.writableEnded) {
694 res.statusCode = 500
671 const status = err.statusCode || 500
672 if (status >= 500) console.error('[Hypernote] Request error:', err.message)
673 res.statusCode = status
695674 res.setHeader('Content-Type', 'application/json')
696 res.end(JSON.stringify({ error: 'Internal server error' }))
675 res.end(JSON.stringify({ error: status >= 500 ? 'Internal server error' : err.message }))
697676 }
698677 }
699678 })
lib/auth.js +0 -1
……@@ -104,7 +104,6 @@ export class AuthManager {
104104
105105 // For other methods, look up User via Identity
106106 let user = await this.identityRegistry.getUserByIdentity(authResult.identityId)
107 console.log(`[Auth] Provider ${authResult.method}: identityId=${authResult.identityId}, user=${user ? user.userId : 'null'}, sharedRegistry=${!!this.sharedRegistry}`)
108107
109108 if (!user && authResult.method === 'web-identity') {
110109 // Web identity was auto-registered by provider, retry lookup
lib/auth/challenge-manager.js +77 -68
……@@ -13,7 +13,7 @@ import b4a from 'b4a'
1313 */
1414export class ChallengeManager {
1515 constructor(options = {}) {
16 this.ttl = options.ttl || 5 * 60 * 1000 // 5 minutes default
16 this.ttl = options.ttl || 60 * 1000 // 60 seconds default (short to limit replay window)
1717
1818 // Persistent storage (optional)
1919 this.db = options.db || null
……@@ -22,10 +22,30 @@ export class ChallengeManager {
2222 this.cache = new Map()
2323 this.cacheTimeout = options.cacheTimeout || 30 * 1000 // 30 seconds
2424
25 // Per-challenge locks to prevent TOCTOU race conditions
26 this._locks = new Map()
27
2528 // Clean up expired challenges every minute
2629 this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 1000)
2730 }
2831
32 /**
33 * Acquire a per-challenge lock to prevent concurrent consume/verify.
34 * Returns a release function.
35 */
36 async _acquireLock (challengeId) {
37 while (this._locks.has(challengeId)) {
38 await this._locks.get(challengeId)
39 }
40 let release
41 const promise = new Promise(resolve => { release = resolve })
42 this._locks.set(challengeId, promise)
43 return () => {
44 this._locks.delete(challengeId)
45 release()
46 }
47 }
48
2949 /**
3050 * Generate new challenge
3151 * @param {Object} options - { context } for logging
……@@ -110,31 +130,33 @@ export class ChallengeManager {
110130 * @returns {Promise<boolean>} True if consumed successfully
111131 */
112132 async consume(challengeId) {
113 // Atomically check and mark as used to prevent race conditions
114 let active = this.cache.get(challengeId)
115 if (active && !active.used && Date.now() <= active.expiresAt) {
116 active.used = true
117 this.cache.set(challengeId, active)
118 } else {
119 // Fall back to storage lookup
120 active = await this.loadChallenge(challengeId)
121 if (!active || active.used || Date.now() > active.expiresAt) return false
122 active.used = true
123 active.cachedAt = Date.now()
124 this.cache.set(challengeId, active)
125 }
133 const release = await this._acquireLock(challengeId)
134 try {
135 let active = this.cache.get(challengeId)
136 if (active && !active.used && Date.now() <= active.expiresAt) {
137 active.used = true
138 this.cache.set(challengeId, active)
139 } else {
140 active = await this.loadChallenge(challengeId)
141 if (!active || active.used || Date.now() > active.expiresAt) return false
142 active.used = true
143 active.cachedAt = Date.now()
144 this.cache.set(challengeId, active)
145 }
126146
127 // Update in storage
128 if (this.db) {
129 try {
130 const { cachedAt, ...data } = active
131 await this.db.put(`challenge:${challengeId}`, data)
132 } catch (err) {
133 console.warn('[Challenge] Failed to mark challenge as used:', err.message)
147 if (this.db) {
148 try {
149 const { cachedAt, ...data } = active
150 await this.db.put(`challenge:${challengeId}`, data)
151 } catch (err) {
152 console.warn('[Challenge] Failed to mark challenge as used:', err.message)
153 }
134154 }
135 }
136155
137 return true
156 return true
157 } finally {
158 release()
159 }
138160 }
139161
140162 /**
……@@ -144,59 +166,48 @@ export class ChallengeManager {
144166 * @returns {Promise<boolean>} True if valid and not expired
145167 */
146168 async verify(challengeId, challenge) {
147 const now = Date.now()
169 const release = await this._acquireLock(challengeId)
170 try {
171 const now = Date.now()
148172
149 // Try cache first
150 let record = this.cache.get(challengeId)
173 let record = this.cache.get(challengeId)
151174
152 // Load from storage if not in cache or cache expired
153 if (!record || (record.cachedAt && now - record.cachedAt > this.cacheTimeout)) {
154 record = await this.loadChallenge(challengeId)
155 if (record) {
156 record.cachedAt = now
157 this.cache.set(challengeId, record)
175 if (!record || (record.cachedAt && now - record.cachedAt > this.cacheTimeout)) {
176 record = await this.loadChallenge(challengeId)
177 if (record) {
178 record.cachedAt = now
179 this.cache.set(challengeId, record)
180 }
158181 }
159 }
160182
161 if (!record) {
162 console.warn('[Challenge] Unknown challenge:', challengeId)
163 return false
164 }
183 if (!record) return false
165184
166 // Check expiry
167 if (now > record.expiresAt) {
168 console.warn('[Challenge] Expired challenge:', challengeId)
169 await this.deleteChallenge(challengeId)
170 return false
171 }
185 if (now > record.expiresAt) {
186 await this.deleteChallenge(challengeId)
187 return false
188 }
172189
173 // Check if already used (prevent replay)
174 if (record.used) {
175 console.warn('[Challenge] Challenge already used:', challengeId)
176 return false
177 }
190 if (record.used) return false
178191
179 // Verify challenge matches
180 if (record.challenge !== challenge) {
181 console.warn('[Challenge] Challenge mismatch:', challengeId)
182 return false
183 }
192 if (record.challenge !== challenge) return false
184193
185 // Mark as used
186 record.used = true
187 this.cache.set(challengeId, record)
194 // Mark as used atomically (under lock)
195 record.used = true
196 this.cache.set(challengeId, record)
188197
189 // Update in storage
190 if (this.db) {
191 try {
192 const { cachedAt, ...data } = record
193 await this.db.put(`challenge:${challengeId}`, data)
194 } catch (err) {
195 console.warn('[Challenge] Failed to mark challenge as used:', err.message)
198 if (this.db) {
199 try {
200 const { cachedAt, ...data } = record
201 await this.db.put(`challenge:${challengeId}`, data)
202 } catch (err) {
203 console.warn('[Challenge] Failed to mark challenge as used:', err.message)
204 }
196205 }
197 }
198206
199 return true
207 return true
208 } finally {
209 release()
210 }
200211 }
201212
202213 /**
……@@ -274,9 +285,7 @@ export class ChallengeManager {
274285 }
275286 }
276287
277 if (cleaned > 0) {
278 console.log(`[Challenge] Cleaned up ${cleaned} expired challenges`)
279 }
288 // Cleanup runs silently
280289 }
281290
282291 /**
lib/auth/providers/bearer-token.js +0 -12
……@@ -24,7 +24,6 @@ export class BearerTokenProvider extends AuthProvider {
2424 * - Authorization: Bearer <token>
2525 * - Authorization: Token <token>
2626 * - X-Auth-Token header
27 * - ?token=<token> query parameter
2827 */
2928 async extractCredentials(req) {
3029 // Check Authorization header
……@@ -38,17 +37,6 @@ export class BearerTokenProvider extends AuthProvider {
3837 const tokenHeader = req.headers['x-auth-token'] || req.headers['X-Auth-Token']
3938 if (tokenHeader) return tokenHeader
4039
41 // Check query parameter
42 if (req.url) {
43 try {
44 const url = new URL(req.url, `http://${req.headers.host}`)
45 const tokenParam = url.searchParams.get('token')
46 if (tokenParam) return tokenParam
47 } catch {
48 // Ignore URL parsing errors
49 }
50 }
51
5240 return null
5341 }
5442
lib/auth/providers/keet-identity.js +1 -1
……@@ -65,7 +65,7 @@ export class KeetIdentityProvider extends AuthProvider {
6565 credentials: { publicKey }
6666 })
6767
68 console.log(`[KeetIdentity] Auto-registered: ${publicKey.substring(0, 8)}... → user ${user.userId}`)
68 console.log('[KeetIdentity] Auto-registered new identity')
6969
7070 return {
7171 identityId: newIdentity.identityId,
lib/multiwriter/manager.js +44 -19
……@@ -9,8 +9,11 @@
99import { SharedRegistry } from './shared-registry.js'
1010import { SharedNotebook } from './shared-notebook.js'
1111import { InstanceLinker } from './instance-linker.js'
12import mutexify from 'mutexify/promise.js'
1213import crypto from 'hypercore-crypto'
1314
15const CALLBACK_TOKEN_TTL = 30 * 60 * 1000 // 30 minutes
16
1417export class MultiwriterManager {
1518 constructor (corestore, opts = {}) {
1619 this._store = corestore
……@@ -23,10 +26,13 @@ export class MultiwriterManager {
2326 this._sharedNotebooks = new Map() // slug -> SharedNotebook
2427 this._ready = false
2528 this._syncInterval = null
29 this._initialSyncTimeout = null
2630 this._driveManager = null
2731 this._replicationManager = null
2832 this._identityRegistry = null
29 this._pendingCallbackTokens = new Set() // one-time tokens for add-writer callback
33 this._pendingCallbackTokens = new Map() // token -> { createdAt }
34 this._lock = mutexify() // serialize state transitions
35 this._addedNbWriters = new Set()
3036 }
3137
3238 get registry () {
……@@ -113,7 +119,7 @@ export class MultiwriterManager {
113119 async createLinkInvite () {
114120 // Generate a one-time callback token for the add-writer HTTP callback
115121 const callbackToken = crypto.randomBytes(32).toString('hex')
116 this._pendingCallbackTokens.add(callbackToken)
122 this._pendingCallbackTokens.set(callbackToken, { createdAt: Date.now() })
117123
118124 // Ensure linker has current instance info + callback token
119125 this._linker._instanceKey = this._instanceKey
……@@ -123,10 +129,17 @@ export class MultiwriterManager {
123129 }
124130
125131 async joinWithInvite (inviteHex) {
126 console.log('[Multiwriter] joinWithInvite: calling linker...')
132 const release = await this._lock()
133 try {
134 return await this._joinWithInviteUnsafe(inviteHex)
135 } finally {
136 release()
137 }
138 }
139
140 async _joinWithInviteUnsafe (inviteHex) {
127141 const result = await this._linker.joinWithInvite(inviteHex)
128142 const { bootstrapKey, writerKey, instanceUrl, callbackToken } = result
129 console.log('[Multiwriter] joinWithInvite: linker returned, bootstrapKey:', bootstrapKey ? bootstrapKey.substring(0, 16) + '...' : 'NONE')
130143
131144 if (!bootstrapKey) throw new Error('No bootstrapKey received from source')
132145
……@@ -134,18 +147,15 @@ export class MultiwriterManager {
134147 const oldRegistry = this._registry
135148 if (this._linker) await this._linker.destroy()
136149
137 // 2. Create NEW SharedRegistry with source's bootstrapKey (unique namespace via Step 2)
150 // 2. Create NEW SharedRegistry with source's bootstrapKey
138151 const bootstrapBuf = Buffer.isBuffer(bootstrapKey)
139152 ? bootstrapKey : Buffer.from(bootstrapKey, 'hex')
140 console.log('[Multiwriter] Creating new SharedRegistry with bootstrap key...')
141153 this._registry = new SharedRegistry(this._store, { bootstrapKey: bootstrapBuf })
142154 await this._registry.ready()
143 console.log('[Multiwriter] New registry ready')
144155
145156 // 3. Get our new localWriterKey from the SHARED registry
146157 const localWriterKey = this._registry.localWriterKey.toString('hex')
147 console.log(`[Multiwriter] Joined shared registry: ${bootstrapKey.substring(0, 16)}...`)
148 console.log(`[Multiwriter] New local writer key: ${localWriterKey.substring(0, 16)}...`)
158 console.log('[Multiwriter] Joined shared registry')
149159
150160 // 4. Re-wire linker with new registry
151161 this._linker = new InstanceLinker(this._registry, {
……@@ -169,7 +179,7 @@ export class MultiwriterManager {
169179 if (result.instanceKey) {
170180 await this._registry.linkInstance(result.instanceKey)
171181 }
172 console.log(`[Multiwriter] Added source writer: ${writerKey.substring(0, 16)}...`)
182 console.log('[Multiwriter] Added source writer')
173183 } catch (err) {
174184 console.error('[Multiwriter] Failed to add source writer:', err.message)
175185 }
……@@ -178,7 +188,7 @@ export class MultiwriterManager {
178188 // 8. HTTP callback — tell source our new writer key
179189 if (instanceUrl) {
180190 try {
181 console.log(`[Multiwriter] Calling back source at ${instanceUrl}/api/multiwriter/add-writer`)
191 console.log('[Multiwriter] Calling back source')
182192 const resp = await fetch(`${instanceUrl}/api/multiwriter/add-writer`, {
183193 method: 'POST',
184194 headers: { 'Content-Type': 'application/json' },
……@@ -187,7 +197,7 @@ export class MultiwriterManager {
187197 if (!resp.ok) {
188198 console.error('[Multiwriter] Callback failed:', resp.status)
189199 } else {
190 console.log('[Multiwriter] Callback succeeded — source has our writer key')
200 console.log('[Multiwriter] Callback succeeded')
191201 }
192202 } catch (err) {
193203 console.error('[Multiwriter] Callback to source failed:', err.message)
……@@ -216,7 +226,9 @@ export class MultiwriterManager {
216226
217227 // Verify callback token if provided (HTTP callback path)
218228 if (opts.callbackToken) {
219 if (!this._pendingCallbackTokens.has(opts.callbackToken)) {
229 const tokenEntry = this._pendingCallbackTokens.get(opts.callbackToken)
230 if (!tokenEntry || (Date.now() - tokenEntry.createdAt > CALLBACK_TOKEN_TTL)) {
231 this._pendingCallbackTokens.delete(opts.callbackToken)
220232 throw new Error('Invalid or expired callback token')
221233 }
222234 this._pendingCallbackTokens.delete(opts.callbackToken) // one-time use
……@@ -242,6 +254,16 @@ export class MultiwriterManager {
242254 async syncMirror () {
243255 if (!this._registry || !this._driveManager) return
244256
257 const release = await this._lock()
258 try {
259 await this._syncMirrorUnsafe()
260 } finally {
261 release()
262 }
263 }
264
265 async _syncMirrorUnsafe () {
266
245267 // 0. Update Autobase to get latest from peers
246268 try {
247269 await this._registry.base.update()
……@@ -500,7 +522,7 @@ export class MultiwriterManager {
500522 for (let i = 0; i < users.length; i++) {
501523 const user = users[i]
502524 if (!user || !user.userId) {
503 console.log(`[Mirror] Skipping user[${i}]: no userId`, JSON.stringify(user).substring(0, 100))
525 console.log(`[Mirror] Skipping user[${i}]: no userId`)
504526 continue
505527 }
506528
……@@ -532,9 +554,8 @@ export class MultiwriterManager {
532554 publicKey,
533555 identityMethod
534556 })
535 console.log(`[Mirror] Synced user: ${user.username || user.userId.substring(0, 16)} (${publicKey ? publicKey.substring(0, 16) + '...' : 'no key'})`)
536557 } catch (err) {
537 console.error(`[Mirror] Failed to sync user ${user.userId.substring(0, 16)}:`, err.message)
558 console.error('[Mirror] Failed to sync user:', err.message)
538559 }
539560 }
540561 } catch (err) {
……@@ -560,7 +581,7 @@ export class MultiwriterManager {
560581 if (su.publicKey) {
561582 const existingRef = await this._identityRegistry.db.get(`pubkey:${su.publicKey}`)
562583 if (existingRef && existingRef.value) {
563 console.log(`[Mirror] Skipping import of ${su.username || su.userId.substring(0, 16)}: publicKey already belongs to local user`)
584 console.log('[Mirror] Skipping import: publicKey already belongs to local user')
564585 continue
565586 }
566587 }
……@@ -607,7 +628,6 @@ export class MultiwriterManager {
607628 }
608629
609630 imported++
610 console.log(`[Mirror] Imported shared user: ${su.username || su.userId.substring(0, 16)} (${su.publicKey ? su.publicKey.substring(0, 16) + '...' : 'no key'})`)
611631 }
612632 if (imported > 0) {
613633 console.log(`[Mirror] Imported ${imported} users from shared registry`)
……@@ -624,7 +644,8 @@ export class MultiwriterManager {
624644 if (this._syncInterval) return
625645
626646 // Initial sync after a short delay (let replication establish)
627 setTimeout(() => {
647 this._initialSyncTimeout = setTimeout(() => {
648 this._initialSyncTimeout = null
628649 this.syncMirror().catch(err => {
629650 console.error('[Mirror] Initial sync failed:', err.message)
630651 })
……@@ -639,6 +660,10 @@ export class MultiwriterManager {
639660 }
640661
641662 stopMirrorSync () {
663 if (this._initialSyncTimeout) {
664 clearTimeout(this._initialSyncTimeout)
665 this._initialSyncTimeout = null
666 }
642667 if (this._syncInterval) {
643668 clearInterval(this._syncInterval)
644669 this._syncInterval = null
lib/multiwriter/shared-notebook.js +11 -4
……@@ -41,6 +41,7 @@ export class SharedNotebook {
4141 // Local blob store — each instance gets its own writable core for media
4242 this._blobCore = this._store.get({ name: 'local-blobs' })
4343 this._blobs = new Hyperblobs(this._blobCore)
44 this._remoteBlobsCache = new Map() // hex key -> Hyperblobs
4445 }
4546
4647 async ready () {
……@@ -251,13 +252,18 @@ export class SharedNotebook {
251252 }
252253
253254 async getBlob (id) {
254 // If blob is on a remote core, open a Hyperblobs session for it
255 // If blob is on a remote core, reuse cached Hyperblobs session
255256 if (id.coreKey) {
257 const keyHex = typeof id.coreKey === 'string' ? id.coreKey : id.coreKey.toString('hex')
256258 const keyBuf = typeof id.coreKey === 'string' ? Buffer.from(id.coreKey, 'hex') : id.coreKey
257259 if (!this._blobCore.key.equals(keyBuf)) {
258 const remoteCore = this._store.get(keyBuf)
259 await remoteCore.ready()
260 const remoteBlobs = new Hyperblobs(remoteCore)
260 let remoteBlobs = this._remoteBlobsCache.get(keyHex)
261 if (!remoteBlobs) {
262 const remoteCore = this._store.get(keyBuf)
263 await remoteCore.ready()
264 remoteBlobs = new Hyperblobs(remoteCore)
265 this._remoteBlobsCache.set(keyHex, remoteBlobs)
266 }
261267 return remoteBlobs.get(id)
262268 }
263269 }
……@@ -278,6 +284,7 @@ export class SharedNotebook {
278284 async close () {
279285 this._closed = true
280286 this._docs.clear()
287 this._remoteBlobsCache.clear()
281288 await this._base.close()
282289 }
283290}
lib/replication.js +2 -9
……@@ -26,15 +26,8 @@ export class ReplicationManager {
2626 this.swarm.on('connection', (conn, info) => {
2727 this.store.replicate(conn)
2828 this._peerCount++
29 console.log(`[Replication] Peer connected (${this._peerCount} total), remote: ${info.publicKey.toString('hex').substring(0, 16)}...`)
30 conn.on('close', () => {
31 this._peerCount--
32 console.log(`[Replication] Peer disconnected (${this._peerCount} total)`)
33 })
34 conn.on('error', (err) => {
35 this._peerCount--
36 console.log(`[Replication] Peer error: ${err.message} (${this._peerCount} total)`)
37 })
29 conn.on('close', () => { this._peerCount-- })
30 conn.on('error', () => { this._peerCount-- })
3831 })
3932 }
4033
lib/response.js +9 -2
……@@ -128,12 +128,19 @@ export async function readBody(req) {
128128}
129129
130130/**
131 * Read and parse request body as JSON
131 * Read and parse request body as JSON.
132 * Returns empty object on empty body or malformed JSON.
132133 */
133134export async function readJson(req) {
134135 const body = await readBody(req)
135136 if (!body) return {}
136 return JSON.parse(body)
137 try {
138 return JSON.parse(body)
139 } catch {
140 const err = new Error('Invalid JSON in request body')
141 err.statusCode = 400
142 throw err
143 }
137144}
138145
139146/**
lib/routes/notebooks.js +47 -12
……@@ -14,14 +14,51 @@ function parseQuery (url) {
1414 const idx = url.indexOf('?')
1515 if (idx < 0) return {}
1616 const qs = url.substring(idx + 1)
17 if (qs.length > 2048) return {} // Prevent DoS via huge query strings
1718 const params = {}
18 for (const pair of qs.split('&')) {
19 const pairs = qs.split('&')
20 if (pairs.length > 50) return {} // Prevent excessive parameter count
21 for (const pair of pairs) {
1922 const [k, v] = pair.split('=')
20 if (k) params[decodeURIComponent(k)] = v ? decodeURIComponent(v) : ''
23 if (k) {
24 try {
25 params[decodeURIComponent(k)] = v ? decodeURIComponent(v) : ''
26 } catch {
27 // Skip malformed URI components
28 }
29 }
2130 }
2231 return params
2332}
2433
34const NOTE_ID_RE = /^[a-f0-9]{16}$/
35const MEDIA_FILENAME_RE = /^[a-zA-Z0-9_\-][a-zA-Z0-9_\-\.]{0,254}$/
36const NOTEBOOK_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$/
37
38function validateNoteId (res, id) {
39 if (!NOTE_ID_RE.test(id)) {
40 json(res, { error: 'Invalid note ID format' }, 400)
41 return false
42 }
43 return true
44}
45
46function validateNotebookSlug (res, slug) {
47 if (!NOTEBOOK_SLUG_RE.test(slug)) {
48 json(res, { error: 'Invalid notebook slug format' }, 400)
49 return false
50 }
51 return true
52}
53
54function validateMediaFilename (res, filename) {
55 if (!MEDIA_FILENAME_RE.test(filename)) {
56 json(res, { error: 'Invalid media filename' }, 400)
57 return false
58 }
59 return true
60}
61
2562/**
2663 * Auth helper — authenticate + check permission, throw on failure
2764 */
……@@ -51,13 +88,9 @@ export function registerNotebookRoutes (router, server) {
5188 if (query.mine === 'true') {
5289 const auth = await server.authManager.authenticate(req)
5390 if (auth && auth.userId) {
54 console.log(`[Notebooks] mine=true filter: userId=${auth.userId.substring(0, 16)}, admin=${auth.permissions?.admin}, total=${notebooks.length}`)
5591 notebooks = notebooks.filter(nb => {
56 const match = nb.ownerId === auth.userId || (!nb.ownerId && auth.permissions && auth.permissions.admin)
57 if (!match) console.log(`[Notebooks] Filtered out: ${nb.slug} ownerId=${nb.ownerId?.substring(0, 16) || 'none'}`)
58 return match
92 return nb.ownerId === auth.userId || (!nb.ownerId && auth.permissions && auth.permissions.admin)
5993 })
60 console.log(`[Notebooks] After filter: ${notebooks.length}`)
6194 } else {
6295 notebooks = []
6396 }
……@@ -180,8 +213,8 @@ export function registerNotebookRoutes (router, server) {
180213 // List notes (open read)
181214 router.get('/api/notebooks/:notebook/notes', async (req, res) => {
182215 const query = parseQuery(req.url)
183 const limit = query.limit ? parseInt(query.limit) : undefined
184 const offset = query.offset ? parseInt(query.offset) : undefined
216 const limit = Math.min(Math.max(1, parseInt(query.limit) || 50), 500)
217 const offset = Math.max(0, parseInt(query.offset) || 0)
185218 const notes = await nm.listNotes(req.params.notebook, { limit, offset })
186219 json(res, { notes })
187220 })
……@@ -202,6 +235,7 @@ export function registerNotebookRoutes (router, server) {
202235
203236 // Get note (open read)
204237 router.get('/api/notebooks/:notebook/notes/:id', async (req, res) => {
238 if (!validateNoteId(res, req.params.id)) return
205239 const note = await nm.getNote(req.params.notebook, req.params.id)
206240 if (!note) {
207241 json(res, { error: 'Note not found', code: 'NOT_FOUND', status: 404 }, 404)
……@@ -212,6 +246,7 @@ export function registerNotebookRoutes (router, server) {
212246
213247 // Update note
214248 router.patch('/api/notebooks/:notebook/notes/:id', async (req, res) => {
249 if (!validateNoteId(res, req.params.id)) return
215250 await requireAuth(req, server, 'write', req.params.notebook)
216251 const body = await readJson(req)
217252 const note = await nm.updateNote(req.params.notebook, req.params.id, {
……@@ -226,6 +261,7 @@ export function registerNotebookRoutes (router, server) {
226261
227262 // Delete note
228263 router.delete('/api/notebooks/:notebook/notes/:id', async (req, res) => {
264 if (!validateNoteId(res, req.params.id)) return
229265 await requireAuth(req, server, 'write', req.params.notebook)
230266 const result = await nm.deleteNote(req.params.notebook, req.params.id)
231267 json(res, result)
……@@ -235,6 +271,7 @@ export function registerNotebookRoutes (router, server) {
235271
236272 // Move note
237273 router.post('/api/notebooks/:notebook/notes/:id/move', async (req, res) => {
274 if (!validateNoteId(res, req.params.id)) return
238275 await requireAuth(req, server, 'write', req.params.notebook)
239276 const body = await readJson(req)
240277 await requireAuth(req, server, 'write', body.toNotebook)
……@@ -295,11 +332,9 @@ export function registerNotebookRoutes (router, server) {
295332 const snb = server.noteManager._getSharedNotebook(req.params.notebook)
296333 if (drive.writable) {
297334 await drive.put(drivePath, body)
298 console.log('[Media Upload] Drive:', drivePath, 'size:', body.length, 'notebook:', req.params.notebook)
299335 } else if (snb) {
300336 const blobId = await snb.storeBlob(body)
301337 await snb.addMedia(null, { hash: filename, mimeType: contentType, filename, size: body.length, blobId }, 'user')
302 console.log('[Media Upload] Blob:', filename, 'size:', body.length, 'blobId:', JSON.stringify(blobId), 'notebook:', req.params.notebook)
303338 } else {
304339 json(res, { error: 'Notebook is read-only on this instance' }, 403)
305340 return
……@@ -313,10 +348,10 @@ export function registerNotebookRoutes (router, server) {
313348
314349 // Serve media from notebook drive
315350 router.get('/api/notebooks/:notebook/media/:filename', async (req, res) => {
351 if (!validateMediaFilename(res, req.params.filename)) return
316352 // No auth for media — public if you have the URL
317353 const drive = await server.driveManager.getDrive(req.params.notebook)
318354 if (!drive) {
319 console.log('[Media Serve] Drive not found for notebook:', req.params.notebook)
320355 res.statusCode = 404
321356 res.end('Not found')
322357 return
lib/routes/web.js +41 -49
……@@ -13,6 +13,20 @@ import fs from 'bare-fs'
1313import path from 'bare-path'
1414import { escapeHtml } from '../response.js'
1515
16// CSP: allow self + inline styles (TOUI editor needs them) + media from self
17const CSP_HEADER = [
18 "default-src 'self'",
19 "script-src 'self'",
20 "style-src 'self' 'unsafe-inline'",
21 "img-src 'self' data: https:",
22 "media-src 'self' https:",
23 "font-src 'self'",
24 "connect-src 'self'",
25 "frame-ancestors 'none'",
26 "base-uri 'self'",
27 "form-action 'self'"
28].join('; ')
29
1630const MIME_TYPES = {
1731 '.html': 'text/html; charset=utf-8',
1832 '.css': 'text/css; charset=utf-8',
……@@ -57,6 +71,11 @@ function serveFile (res, filePath) {
5771 res.setHeader('Content-Type', contentType)
5872 res.setHeader('X-Content-Type-Options', 'nosniff')
5973 res.setHeader('Cache-Control', 'no-cache')
74 if (ext === '.html') {
75 res.setHeader('Content-Security-Policy', CSP_HEADER)
76 res.setHeader('X-Frame-Options', 'DENY')
77 res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
78 }
6079 res.end(content)
6180 } catch (err) {
6281 res.statusCode = 404
……@@ -68,6 +87,16 @@ function serveFile (res, filePath) {
6887/**
6988 * Render a simple HTML page (for public URL views)
7089 */
90function sendHtml (res, html, status = 200) {
91 res.statusCode = status
92 res.setHeader('Content-Type', 'text/html; charset=utf-8')
93 res.setHeader('Content-Security-Policy', CSP_HEADER)
94 res.setHeader('X-Frame-Options', 'DENY')
95 res.setHeader('X-Content-Type-Options', 'nosniff')
96 res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
97 res.end(html)
98}
99
71100function renderPage (title, body) {
72101 return `<!DOCTYPE html>
73102<html lang="en">
……@@ -120,6 +149,7 @@ export function registerWebRoutes (router, server) {
120149 const html = getIndexHtml(webRoot)
121150 res.statusCode = 200
122151 res.setHeader('Content-Type', 'text/html; charset=utf-8')
152 res.setHeader('Content-Security-Policy', CSP_HEADER)
123153 res.setHeader('X-Frame-Options', 'DENY')
124154 res.setHeader('X-Content-Type-Options', 'nosniff')
125155 res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
……@@ -204,9 +234,7 @@ export function registerPublicRoutes (router, server) {
204234 const user = await server.authManager.identityRegistry.getUserByUsername(username)
205235 if (!user) {
206236 const html = renderPage('Not Found', '<h1>User not found</h1><p>No user with username <strong>@' + escapeHtml(username) + '</strong></p>')
207 res.statusCode = 404
208 res.setHeader('Content-Type', 'text/html; charset=utf-8')
209 res.end(html)
237 sendHtml(res, html, 404)
210238 return
211239 }
212240
……@@ -229,10 +257,7 @@ export function registerPublicRoutes (router, server) {
229257 }
230258 }
231259
232 const html = renderPage('@' + username, body)
233 res.statusCode = 200
234 res.setHeader('Content-Type', 'text/html; charset=utf-8')
235 res.end(html)
260 sendHtml(res, renderPage('@' + username, body))
236261 })
237262
238263 // GET /:username/:book — List notes in shared notebook
……@@ -241,29 +266,17 @@ export function registerPublicRoutes (router, server) {
241266
242267 const user = await server.authManager.identityRegistry.getUserByUsername(username)
243268 if (!user) {
244 const html = renderPage('Not Found', '<h1>User not found</h1>')
245 res.statusCode = 404
246 res.setHeader('Content-Type', 'text/html; charset=utf-8')
247 res.end(html)
248 return
269 return sendHtml(res, renderPage('Not Found', '<h1>User not found</h1>'), 404)
249270 }
250271
251272 // Check drive ownership and sharing
252273 const notebook = await server.noteManager.getNotebook(book)
253274 if (!notebook || notebook.ownerId !== user.userId) {
254 const html = renderPage('Not Found', '<h1>Notebook not found</h1>')
255 res.statusCode = 404
256 res.setHeader('Content-Type', 'text/html; charset=utf-8')
257 res.end(html)
258 return
275 return sendHtml(res, renderPage('Not Found', '<h1>Notebook not found</h1>'), 404)
259276 }
260277
261278 if (notebook.visibility === 'private' || notebook.encrypted) {
262 const html = renderPage('Private', '<h1>Notebook is private</h1><p>This notebook has not been shared publicly.</p>')
263 res.statusCode = 403
264 res.setHeader('Content-Type', 'text/html; charset=utf-8')
265 res.end(html)
266 return
279 return sendHtml(res, renderPage('Private', '<h1>Notebook is private</h1><p>This notebook has not been shared publicly.</p>'), 403)
267280 }
268281
269282 const noteList = await server.noteManager.listNotes(book)
……@@ -286,10 +299,7 @@ export function registerPublicRoutes (router, server) {
286299 }
287300 }
288301
289 const html = renderPage((notebook && notebook.name) || book, body)
290 res.statusCode = 200
291 res.setHeader('Content-Type', 'text/html; charset=utf-8')
292 res.end(html)
302 sendHtml(res, renderPage((notebook && notebook.name) || book, body))
293303 })
294304
295305 // GET /:username/:book/:noteId — View single note
……@@ -298,29 +308,17 @@ export function registerPublicRoutes (router, server) {
298308
299309 const user = await server.authManager.identityRegistry.getUserByUsername(username)
300310 if (!user) {
301 const html = renderPage('Not Found', '<h1>User not found</h1>')
302 res.statusCode = 404
303 res.setHeader('Content-Type', 'text/html; charset=utf-8')
304 res.end(html)
305 return
311 return sendHtml(res, renderPage('Not Found', '<h1>User not found</h1>'), 404)
306312 }
307313
308314 // Check notebook ownership and visibility
309315 const notebook = await server.noteManager.getNotebook(book)
310316 if (!notebook || notebook.ownerId !== user.userId) {
311 const html = renderPage('Not Found', '<h1>Note not found</h1>')
312 res.statusCode = 404
313 res.setHeader('Content-Type', 'text/html; charset=utf-8')
314 res.end(html)
315 return
317 return sendHtml(res, renderPage('Not Found', '<h1>Note not found</h1>'), 404)
316318 }
317319
318320 if (notebook.visibility === 'private' || notebook.encrypted) {
319 const html = renderPage('Private', '<h1>Notebook is private</h1>')
320 res.statusCode = 403
321 res.setHeader('Content-Type', 'text/html; charset=utf-8')
322 res.end(html)
323 return
321 return sendHtml(res, renderPage('Private', '<h1>Notebook is private</h1>'), 403)
324322 }
325323
326324 // Get note
……@@ -341,15 +339,9 @@ export function registerPublicRoutes (router, server) {
341339 body += '<div class="meta">' + new Date(note.updatedAt || note.createdAt).toLocaleDateString() + '</div>'
342340 body += '<div class="markdown-body">' + escapeHtml(note.content || '') + '</div>'
343341
344 const html = renderPage(note.title || 'Note', body)
345 res.statusCode = 200
346 res.setHeader('Content-Type', 'text/html; charset=utf-8')
347 res.end(html)
342 sendHtml(res, renderPage(note.title || 'Note', body))
348343 } catch (err) {
349 const html = renderPage('Not Found', '<h1>Note not found</h1>')
350 res.statusCode = 404
351 res.setHeader('Content-Type', 'text/html; charset=utf-8')
352 res.end(html)
344 sendHtml(res, renderPage('Not Found', '<h1>Note not found</h1>'), 404)
353345 }
354346 })
355347}
web/js/markdown.js +38 -6
……@@ -9,7 +9,35 @@ window.renderMarkdown = function (src) {
99
1010 // Escape HTML
1111 function esc (s) {
12 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
12 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
13 }
14
15 // Validate URL protocol — only allow safe protocols
16 function safeUrl (url) {
17 var decoded = url
18 try { decoded = decodeURIComponent(url) } catch (e) {}
19 var trimmed = decoded.trim().toLowerCase()
20 if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.startsWith('vbscript:')) return ''
21 return url
22 }
23
24 // Sanitize HTML media tag — strip all attributes except safe ones
25 function sanitizeMediaTag (html) {
26 var safeAttrs = /^\s*(src|controls|width|height|style|preload|poster|loop|muted|autoplay|alt|class|id)$/i
27 return html.replace(/<(\w+)(\s[^>]*)>/g, function (match, tag, attrs) {
28 var cleaned = attrs.replace(/\s*([\w-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/g, function (a, name, val) {
29 if (!safeAttrs.test(name)) return ''
30 // Also check src values for dangerous protocols
31 if (name.toLowerCase() === 'src') {
32 var unquoted = val.replace(/^["']|["']$/g, '')
33 if (!safeUrl(unquoted)) return ''
34 }
35 return a
36 })
37 // Strip bare event handlers like onerror onload etc
38 cleaned = cleaned.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '')
39 return '<' + tag + cleaned + '>'
40 })
1341 }
1442
1543 var lines = src.split('\n')
……@@ -40,7 +68,7 @@ window.renderMarkdown = function (src) {
4068 for (var gi = 0; gi < galleryImages.length; gi++) {
4169 var img = galleryImages[gi]
4270 html.push('<div class="gallery-item" data-gallery="' + gid + '" data-index="' + gi + '">')
43 html.push('<img src="' + img.src + '" alt="' + esc(img.alt) + '" loading="lazy">')
71 html.push('<img src="' + safeUrl(img.src) + '" alt="' + esc(img.alt) + '" loading="lazy">')
4472 if (img.alt) html.push('<div class="gallery-caption">' + esc(img.alt) + '</div>')
4573 html.push('</div>')
4674 }
……@@ -137,7 +165,7 @@ window.renderMarkdown = function (src) {
137165 continue
138166 }
139167
140 // Pass through HTML media tags (video, audio) without escaping
168 // Pass through HTML media tags (video, audio) with sanitization
141169 if (/^\s*<(video|audio)[\s>]/.test(line)) {
142170 var tag = RegExp.$1
143171 var block = [line]
……@@ -145,7 +173,7 @@ window.renderMarkdown = function (src) {
145173 i++
146174 block.push(lines[i])
147175 }
148 html.push(block.join('\n'))
176 html.push(sanitizeMediaTag(block.join('\n')))
149177 continue
150178 }
151179
……@@ -199,14 +227,18 @@ window.renderMarkdown = function (src) {
199227 var videoExts = /\.(mp4|webm|ogg|mov)$/i
200228 text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_, alt, url) {
201229 var src = (url.startsWith('/api/') && bp) ? bp + url : url
230 src = safeUrl(src)
202231 if (videoExts.test(url)) {
203232 return '<video controls style="max-width:100%"><source src="' + src + '"></video>'
204233 }
205234 return '<img alt="' + alt + '" src="' + src + '">'
206235 })
207236
208 // Links [text](url)
209 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
237 // Links [text](url) — sanitize href to prevent javascript: XSS
238 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, linkText, url) {
239 var href = safeUrl(url)
240 return '<a href="' + href + '">' + linkText + '</a>'
241 })
210242
211243 // Bold **text** or __text__
212244 text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')