Skip to content

Commit 0bb6015

Browse files
Disane87claude
andcommitted
fix(security): SSRF guard, header redaction, dashboard token gate
* server/utils/og.ts: resolve target host and block private/loopback/ link-local/metadata IPs before fetching link previews; follow redirects manually so each hop is re-validated. * server/utils/meta.ts: redact Cookie, Authorization and similar bearer headers before persisting visitor metadata, replacing values with a length stub so analysts still see the header was present. * server/middleware/dashboard-auth.ts: gate every /api/* call behind HONEY_DASHBOARD_TOKEN; fail closed when the env var is unset unless HONEY_AUTH_DISABLED=1 is explicitly set for local development. * .env.example: document the two new environment variables. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 261bcb9 commit 0bb6015

4 files changed

Lines changed: 181 additions & 3 deletions

File tree

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,13 @@ NUXT_PUBLIC_BASE_URL=https://honey.example.com
66
# Outbound IP -> geo enrichment via ip-api.com (true/false).
77
# Set to false to keep everything local with no outbound requests.
88
NUXT_GEO_LOOKUP=true
9+
10+
# Shared secret that gates every /api/* dashboard endpoint. Required in
11+
# production — without it the server returns 503 for all dashboard calls.
12+
# Clients send it as `Authorization: Bearer <token>`, `?token=<token>`, or
13+
# Cookie `honey_token=<token>`.
14+
HONEY_DASHBOARD_TOKEN=
15+
16+
# Explicit opt-out for local development. Set to "1" to allow open access
17+
# without a token. NEVER set this in production.
18+
HONEY_AUTH_DISABLED=
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Gates every /api/* dashboard endpoint behind a shared token. The honeypot
3+
* streams victim PII (IPs, headers, cookies) so leaving these endpoints open
4+
* to the internet is a data-protection problem.
5+
*
6+
* Set HONEY_DASHBOARD_TOKEN in the environment. Clients send it as either:
7+
* Authorization: Bearer <token>
8+
* ?token=<token>
9+
* Cookie: honey_token=<token>
10+
*
11+
* If the env var is unset, the server refuses every dashboard request — fail
12+
* closed. Set HONEY_AUTH_DISABLED=1 to explicitly allow open access (dev only).
13+
*/
14+
15+
const SAFE_PATHS = ['/api/_nuxt_icon']
16+
17+
function timingSafeEqual(a: string, b: string): boolean {
18+
if (a.length !== b.length) return false
19+
let r = 0
20+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i)
21+
return r === 0
22+
}
23+
24+
export default defineEventHandler((event) => {
25+
const path = event.path || ''
26+
if (!path.startsWith('/api/')) return
27+
if (SAFE_PATHS.some((p) => path.startsWith(p))) return
28+
29+
if (process.env.HONEY_AUTH_DISABLED === '1') return
30+
31+
const expected = process.env.HONEY_DASHBOARD_TOKEN
32+
if (!expected) {
33+
throw createError({
34+
statusCode: 503,
35+
statusMessage: 'Dashboard auth not configured (set HONEY_DASHBOARD_TOKEN)'
36+
})
37+
}
38+
39+
const auth = getRequestHeader(event, 'authorization') || ''
40+
const bearer = auth.replace(/^Bearer\s+/i, '')
41+
const provided =
42+
(bearer && bearer !== auth ? bearer : '') ||
43+
(getQuery(event).token as string | undefined) ||
44+
getCookie(event, 'honey_token') ||
45+
''
46+
47+
if (!provided || !timingSafeEqual(provided, expected)) {
48+
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
49+
}
50+
})

server/utils/meta.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,35 @@ async function geoLookup(ip: string): Promise<GeoInfo | undefined> {
8686
}
8787
}
8888

89+
/**
90+
* Header values too sensitive to persist verbatim. We keep the header name in
91+
* the record (so analysts know it was sent) but replace the value with a
92+
* length-stub. Visitors clicking traps from authenticated browsers will leak
93+
* their session tokens here otherwise.
94+
*/
95+
const REDACTED_HEADERS = new Set([
96+
'cookie',
97+
'set-cookie',
98+
'authorization',
99+
'proxy-authorization',
100+
'x-api-key',
101+
'x-auth-token',
102+
'x-csrf-token',
103+
'x-xsrf-token'
104+
])
105+
106+
function redactHeaders(headers: Record<string, string>): Record<string, string> {
107+
const out: Record<string, string> = {}
108+
for (const [name, value] of Object.entries(headers)) {
109+
if (REDACTED_HEADERS.has(name.toLowerCase()) && value) {
110+
out[name] = `[redacted ${value.length} chars]`
111+
} else {
112+
out[name] = value
113+
}
114+
}
115+
return out
116+
}
117+
89118
/**
90119
* Build a full Hit record from the incoming request. This is where all the
91120
* juicy honeypot metadata gets collected.
@@ -114,6 +143,6 @@ export async function buildHit(event: H3Event): Promise<Omit<Hit, 'id' | 'trapId
114143
geo,
115144
isBot: bot.isBot,
116145
botReason: bot.reason,
117-
headers
146+
headers: redactHeaders(headers)
118147
}
119148
}

server/utils/og.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,98 @@
1+
import { Resolver } from 'node:dns/promises'
2+
import { isIP } from 'node:net'
13
import type { CustomPreview, OgData, OgTag } from './types'
24

35
const FETCH_UA =
46
'Mozilla/5.0 (compatible; hon.ey-link-preview/1.0; +https://github.com/) facebookexternalhit/1.1'
57

8+
const resolver = new Resolver()
9+
10+
/**
11+
* Reject loopback, link-local, cloud-metadata, and RFC1918 ranges so a malicious
12+
* trap target cannot pivot fetchOgData into the host's internal network.
13+
*/
14+
function isPrivateAddress(addr: string): boolean {
15+
const v = isIP(addr)
16+
if (v === 4) {
17+
const [a, b] = addr.split('.').map(Number)
18+
if (a === 10) return true
19+
if (a === 127) return true
20+
if (a === 0) return true
21+
if (a === 169 && b === 254) return true // link-local + AWS/GCP metadata
22+
if (a === 172 && b >= 16 && b <= 31) return true
23+
if (a === 192 && b === 168) return true
24+
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT
25+
if (a >= 224) return true // multicast + reserved
26+
return false
27+
}
28+
if (v === 6) {
29+
const lower = addr.toLowerCase()
30+
if (lower === '::1' || lower === '::' || lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return true
31+
if (lower.startsWith('::ffff:')) return isPrivateAddress(lower.slice(7))
32+
if (lower.startsWith('2001:db8:')) return true
33+
return false
34+
}
35+
return true
36+
}
37+
38+
async function assertPublicHost(urlStr: string): Promise<void> {
39+
let parsed: URL
40+
try {
41+
parsed = new URL(urlStr)
42+
} catch {
43+
throw new Error('Invalid URL')
44+
}
45+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
46+
throw new Error(`Refusing non-http(s) scheme: ${parsed.protocol}`)
47+
}
48+
const host = parsed.hostname
49+
if (!host) throw new Error('URL has no host')
50+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
51+
throw new Error(`Refusing internal host: ${host}`)
52+
}
53+
54+
const literal = isIP(host)
55+
if (literal) {
56+
if (isPrivateAddress(host)) throw new Error(`Refusing private IP: ${host}`)
57+
return
58+
}
59+
60+
const addrs: string[] = []
61+
for (const fn of ['resolve4', 'resolve6'] as const) {
62+
try {
63+
const r = await resolver[fn](host)
64+
addrs.push(...r)
65+
} catch {}
66+
}
67+
if (!addrs.length) throw new Error(`Could not resolve host: ${host}`)
68+
for (const a of addrs) {
69+
if (isPrivateAddress(a)) throw new Error(`Host ${host} resolves to private address ${a}`)
70+
}
71+
}
72+
73+
/**
74+
* Follow redirects manually so each hop's hostname can be re-validated against
75+
* the private-network blocklist. Native fetch with redirect:'follow' would let
76+
* an attacker bounce us from a public host into 169.254.169.254.
77+
*/
78+
async function safePublicFetch(url: string, init: RequestInit & { maxRedirects?: number }): Promise<Response> {
79+
const max = init.maxRedirects ?? 5
80+
let current = url
81+
const { maxRedirects: _omit, ...passthrough } = init
82+
for (let i = 0; i <= max; i++) {
83+
await assertPublicHost(current)
84+
const res = await fetch(current, { ...passthrough, redirect: 'manual' })
85+
if (res.status >= 300 && res.status < 400) {
86+
const loc = res.headers.get('location')
87+
if (!loc) return res
88+
current = new URL(loc, current).toString()
89+
continue
90+
}
91+
return res
92+
}
93+
throw new Error('Too many redirects')
94+
}
95+
696
/** Which meta tags are worth cloning for a link preview. */
797
function wantMeta(attr: 'property' | 'name', key: string): boolean {
898
const k = key.toLowerCase()
@@ -56,9 +146,8 @@ export async function fetchOgData(targetUrl: string): Promise<OgData> {
56146
try {
57147
const controller = new AbortController()
58148
const t = setTimeout(() => controller.abort(), 8000)
59-
const res = await fetch(targetUrl, {
149+
const res = await safePublicFetch(targetUrl, {
60150
headers: { 'User-Agent': FETCH_UA, Accept: 'text/html,application/xhtml+xml' },
61-
redirect: 'follow',
62151
signal: controller.signal
63152
}).finally(() => clearTimeout(t))
64153

0 commit comments

Comments
 (0)