( originHeader: string | undefined, hostHeader: string | undefined, allowedHosts: Set<string> )
| 180 | * origin is never echoed back into `Access-Control-Allow-Origin`. |
| 181 | */ |
| 182 | export function validateOrigin( |
| 183 | originHeader: string | undefined, |
| 184 | hostHeader: string | undefined, |
| 185 | allowedHosts: Set<string> |
| 186 | ): OriginValidation { |
| 187 | const allowAny = allowedHosts.has(ALLOW_ANY_HOST); |
| 188 | |
| 189 | // 1. Validate the Host header (the rebinding defense — runs unconditionally). |
| 190 | const trimmedHost = (hostHeader ?? "").trim(); |
| 191 | if (!trimmedHost || INVALID_HOST_CHARS.test(trimmedHost)) { |
| 192 | return { ok: false, status: 400, message: "Malformed Host header" }; |
| 193 | } |
| 194 | |
| 195 | let hostname: string; |
| 196 | try { |
| 197 | hostname = new URL(`http://${trimmedHost}`).hostname.toLowerCase(); |
| 198 | } catch { |
| 199 | return { ok: false, status: 400, message: "Malformed Host header" }; |
| 200 | } |
| 201 | if (!hostname) { |
| 202 | return { ok: false, status: 400, message: "Malformed Host header" }; |
| 203 | } |
| 204 | |
| 205 | if (!allowAny && !allowedHosts.has(hostname)) { |
| 206 | return { |
| 207 | ok: false, |
| 208 | status: 403, |
| 209 | message: |
| 210 | `Host '${hostname}' is not allowed. Only loopback is permitted by default; ` + |
| 211 | `set --allowed-hosts (or DBHUB_ALLOWED_HOSTS) to serve other hostnames. ` + |
| 212 | `This protects against DNS rebinding.`, |
| 213 | }; |
| 214 | } |
| 215 | |
| 216 | // 2. Origin is only sent by browsers on cross-origin fetches; non-browser |
| 217 | // MCP clients omit it. When present it must also be an allowed host so we |
| 218 | // never reflect an untrusted origin in the CORS response. |
| 219 | if (originHeader === undefined) return { ok: true }; |
| 220 | |
| 221 | const trimmedOrigin = originHeader.trim(); |
| 222 | if (!trimmedOrigin) { |
| 223 | return { ok: false, status: 400, message: "Malformed Origin header" }; |
| 224 | } |
| 225 | |
| 226 | let originHostname: string; |
| 227 | try { |
| 228 | originHostname = new URL(trimmedOrigin).hostname.toLowerCase(); |
| 229 | } catch { |
| 230 | return { ok: false, status: 400, message: "Malformed Origin header" }; |
| 231 | } |
| 232 | if (!originHostname) { |
| 233 | return { ok: false, status: 400, message: "Malformed Origin header" }; |
| 234 | } |
| 235 | |
| 236 | if (!allowAny && !allowedHosts.has(originHostname)) { |
| 237 | return { |
| 238 | ok: false, |
| 239 | status: 403, |
no outgoing calls
no test coverage detected