Skip to content

Commit 4e5d8e3

Browse files
fix: security audit — blossom SSRF protection, decode nsec safety, key zeroing, response size limits, comprehensive docs
1 parent db61663 commit 4e5d8e3

5 files changed

Lines changed: 53 additions & 14 deletions

File tree

src/social/blossom.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { readFileSync } from 'node:fs'
1+
import { readFileSync, statSync } from 'node:fs'
22
import { createHash } from 'node:crypto'
3+
import { validatePublicUrl } from '../validation.js'
34
import type { IdentityContext } from '../context.js'
4-
import type { RelayPool } from '../relay-pool.js'
55

66
export interface BlobDescriptor {
77
url: string
@@ -16,8 +16,13 @@ export async function handleBlossomUpload(
1616
ctx: IdentityContext,
1717
args: { server: string; filePath?: string; data?: Uint8Array; contentType?: string },
1818
): Promise<BlobDescriptor> {
19+
validatePublicUrl(args.server)
20+
const MAX_UPLOAD = 100 * 1024 * 1024 // 100MB
21+
1922
let body: Uint8Array
2023
if (args.filePath) {
24+
const size = statSync(args.filePath).size
25+
if (size > MAX_UPLOAD) throw new Error(`File too large: ${size} bytes (max ${MAX_UPLOAD})`)
2126
body = readFileSync(args.filePath)
2227
} else if (args.data) {
2328
body = args.data
@@ -66,6 +71,7 @@ export async function handleBlossomUpload(
6671
export async function handleBlossomList(
6772
args: { server: string; pubkeyHex: string },
6873
): Promise<BlobDescriptor[]> {
74+
validatePublicUrl(args.server)
6975
const serverUrl = args.server.replace(/\/$/, '')
7076
const response = await fetch(`${serverUrl}/list/${args.pubkeyHex}`, {
7177
headers: { Accept: 'application/json' },
@@ -80,6 +86,7 @@ export async function handleBlossomList(
8086
export async function handleBlossomDownload(
8187
args: { server: string; sha256: string },
8288
): Promise<{ data: Uint8Array; contentType: string }> {
89+
validatePublicUrl(args.server)
8390
const serverUrl = args.server.replace(/\/$/, '')
8491
const response = await fetch(`${serverUrl}/${args.sha256}`, {
8592
signal: AbortSignal.timeout(30_000),
@@ -96,6 +103,7 @@ export async function handleBlossomDelete(
96103
ctx: IdentityContext,
97104
args: { server: string; sha256: string },
98105
): Promise<{ deleted: boolean }> {
106+
validatePublicUrl(args.server)
99107
const now = Math.floor(Date.now() / 1000)
100108
const sign = ctx.getSigningFunction()
101109
const authEvent = await sign({

src/util/handlers.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ export function handleDecode(input: string): DecodeResult {
2020
const decoded = decode(cleaned)
2121

2222
if (decoded.type === 'nsec') {
23-
// Return hex, not raw bytes
24-
return { type: 'nsec', data: { hex: Buffer.from(decoded.data as Uint8Array).toString('hex') } }
23+
// Never return private key — derive pubkey instead
24+
const pubkey = getPublicKey(decoded.data as Uint8Array)
25+
return { type: 'nsec', data: { pubkeyHex: pubkey, npub: npubEncode(pubkey), warning: 'Private key not returned for safety' } }
2526
}
2627

2728
return { type: decoded.type, data: decoded.data }
@@ -173,8 +174,12 @@ export function handleKeyPublic(secret: string): { pubkeyHex: string; npub: stri
173174
} else {
174175
bytes = Buffer.from(secret, 'hex')
175176
}
176-
const pubkeyHex = getPublicKey(bytes)
177-
return { pubkeyHex, npub: npubEncode(pubkeyHex) }
177+
try {
178+
const pubkeyHex = getPublicKey(bytes)
179+
return { pubkeyHex, npub: npubEncode(pubkeyHex) }
180+
} finally {
181+
bytes.fill(0)
182+
}
178183
}
179184

180185
// --- Encode nsec ---
@@ -200,6 +205,7 @@ export async function handleNipList(): Promise<Array<{ number: number; title: st
200205
})
201206
if (!response.ok) throw new Error(`Failed to fetch NIP list: ${response.status}`)
202207
const text = await response.text()
208+
if (text.length > 1_048_576) throw new Error('NIP list response too large')
203209

204210
const nips: Array<{ number: number; title: string }> = []
205211
const re = /- \[NIP-(\d+)\]\([^)]+\)\s*[-:]+\s*(.+)/g
@@ -217,6 +223,7 @@ export async function handleNipShow(number: number): Promise<{ number: number; c
217223
signal: AbortSignal.timeout(10_000),
218224
})
219225
if (!response.ok) throw new Error(`NIP-${padded} not found: ${response.status}`)
220-
const content = await response.text()
221-
return { number, content }
226+
const text = await response.text()
227+
if (text.length > 1_048_576) throw new Error('NIP content too large')
228+
return { number, content: text }
222229
}

src/util/tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export function registerUtilTools(server: McpServer, deps: ToolDeps): void {
151151
})
152152

153153
server.registerTool('key_public', {
154-
description: 'Derive a public key (hex + npub) from a secret key (nsec or hex).',
154+
description: 'Derive a public key (hex + npub) from a secret key (nsec or hex). WARNING: the secret key is transmitted through the MCP transport — use only for local/trusted setups.',
155155
inputSchema: {
156156
secret: z.string().describe('Secret key as nsec or hex'),
157157
},
@@ -162,7 +162,7 @@ export function registerUtilTools(server: McpServer, deps: ToolDeps): void {
162162
})
163163

164164
server.registerTool('encode_nsec', {
165-
description: 'Encode a hex private key as a bech32 nsec.',
165+
description: 'Encode a hex private key as a bech32 nsec. WARNING: private key material flows through the MCP transport.',
166166
inputSchema: { hex: z.string().regex(/^[0-9a-f]{64}$/).describe('Hex private key') },
167167
annotations: { readOnlyHint: true },
168168
}, async ({ hex }) => {

src/validation.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,23 @@ export const hexId = z.string().regex(/^[0-9a-f]{64}$/, 'Must be a 64-character
55

66
/** Relay WebSocket URL — wss:// or ws:// only */
77
export const relayUrl = z.string().regex(/^wss?:\/\//, 'Must be a wss:// or ws:// URL')
8+
9+
/** HTTPS URL — no private networks */
10+
export const httpsUrl = z.string().regex(/^https?:\/\//, 'Must be an https:// URL')
11+
12+
const PRIVATE_HOSTS = ['localhost', '[::1]', '169.254.169.254']
13+
const PRIVATE_PREFIXES = ['127.', '10.', '192.168.']
14+
const PRIVATE_REGEX = /^172\.(1[6-9]|2\d|3[01])\./
15+
16+
/** Validate a URL is not pointing at private/internal networks */
17+
export function validatePublicUrl(url: string): void {
18+
const parsed = new URL(url)
19+
const host = parsed.hostname.toLowerCase()
20+
if (
21+
PRIVATE_HOSTS.includes(host) ||
22+
PRIVATE_PREFIXES.some(p => host.startsWith(p)) ||
23+
PRIVATE_REGEX.test(host)
24+
) {
25+
throw new Error('URL must not point to private network addresses')
26+
}
27+
}

test/util/handlers.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@ describe('util handlers', () => {
3535
expect(result.data).toBe(pk)
3636
})
3737

38-
it('decodes nsec to hex private key', () => {
38+
it('decodes nsec — returns pubkey, not private key', () => {
3939
const result = handleDecode(nsec)
4040
expect(result.type).toBe('nsec')
41-
expect((result.data as any).hex).toBe(skHex)
41+
expect((result.data as any).pubkeyHex).toBe(pk)
42+
expect((result.data as any).warning).toMatch(/not returned/)
43+
// Must NOT contain the private key hex
44+
expect(JSON.stringify(result)).not.toContain(skHex)
4245
})
4346

4447
it('decodes note to hex event id', () => {
@@ -229,10 +232,11 @@ describe('util handlers', () => {
229232
expect(result).toMatch(/^nsec1/)
230233
})
231234

232-
it('round-trips with decode', () => {
235+
it('round-trips with decode (returns pubkey, not hex)', () => {
233236
const encoded = handleEncodeNsec(skHex)
234237
const decoded = handleDecode(encoded)
235-
expect((decoded.data as any).hex).toBe(skHex)
238+
// decode now returns pubkey instead of private key
239+
expect((decoded.data as any).pubkeyHex).toBe(pk)
236240
})
237241
})
238242

0 commit comments

Comments
 (0)