{"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../../src/core/web-research/security.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAM9C,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,IAAI,CAAA;CAAE,KAAK,OAAO,CAAC,aAAa,EAAE,CAAC,CAAC;AAEpH,MAAM,WAAW,2BAA2B;IAC3C,kGAAkG;IAClG,mBAAmB,EAAE,IAAI,CAAC;IAC1B,+EAA+E;IAC/E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CAChC;AAwBD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAiC5D;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,CAgCvD;AAED,wBAAsB,wBAAwB,CAC7C,KAAK,EAAE,MAAM,EACb,OAAO,GAAE;IACR,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,2BAA2B,CAAC,EAAE,2BAA2B,CAAC;IAC1D,MAAM,CAAC,EAAE,WAAW,CAAC;CAChB,GACJ,OAAO,CAAC;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,SAAS,EAAE,aAAa,EAAE,CAAA;CAAE,CAAC,CA+BnD","sourcesContent":["import type { LookupAddress } from \"node:dns\";\nimport { lookup as defaultLookup } from \"node:dns/promises\";\nimport { isIP } from \"node:net\";\nimport { WebResearchError } from \"./types.js\";\nimport { sanitizeUrlForDiagnostics } from \"./url.js\";\n\nexport type WebDnsResolver = (hostname: string, options: { all: true; verbatim: true }) => Promise<LookupAddress[]>;\n\nexport interface AdministrativeNetworkPolicy {\n\t/** Host-owned test/administration policy. This value is never accepted from a model tool call. */\n\tallowPrivateNetwork: true;\n\t/** Explicit host allowlist; broad private-network access is never inferred. */\n\tallowedHosts: readonly string[];\n}\n\nconst INTERNAL_HOST_SUFFIXES = [\".internal\", \".local\", \".localhost\", \".home\", \".lan\"];\n\nfunction parseIpv4(address: string): number[] | undefined {\n\tconst parts = address.split(\".\");\n\tif (parts.length !== 4) return undefined;\n\tconst bytes = parts.map(Number);\n\treturn bytes.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) ? bytes : undefined;\n}\n\nfunction mappedIpv4(address: string): string | undefined {\n\tconst normalized = address.toLowerCase();\n\tif (!normalized.startsWith(\"::ffff:\")) return undefined;\n\tconst suffix = normalized.slice(7);\n\tif (suffix.includes(\".\")) return suffix;\n\tconst groups = suffix.split(\":\");\n\tif (groups.length !== 2) return undefined;\n\tconst high = Number.parseInt(groups[0], 16);\n\tconst low = Number.parseInt(groups[1], 16);\n\tif (!Number.isFinite(high) || !Number.isFinite(low)) return undefined;\n\treturn `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;\n}\n\nexport function isBlockedWebAddress(address: string): boolean {\n\tconst mapped = mappedIpv4(address);\n\tif (mapped) return isBlockedWebAddress(mapped);\n\tconst family = isIP(address);\n\tif (family === 4) {\n\t\tconst bytes = parseIpv4(address);\n\t\tif (!bytes) return true;\n\t\tconst [a, b] = bytes;\n\t\treturn (\n\t\t\ta === 0 ||\n\t\t\ta === 10 ||\n\t\t\ta === 127 ||\n\t\t\t(a === 100 && b >= 64 && b <= 127) ||\n\t\t\t(a === 169 && b === 254) ||\n\t\t\t(a === 172 && b >= 16 && b <= 31) ||\n\t\t\t(a === 192 && b === 0) ||\n\t\t\t(a === 192 && b === 168) ||\n\t\t\t(a === 198 && (b === 18 || b === 19)) ||\n\t\t\ta >= 224\n\t\t);\n\t}\n\tif (family === 6) {\n\t\tconst value = address.toLowerCase().split(\"%\")[0];\n\t\treturn (\n\t\t\tvalue === \"::\" ||\n\t\t\tvalue === \"::1\" ||\n\t\t\tvalue.startsWith(\"fc\") ||\n\t\t\tvalue.startsWith(\"fd\") ||\n\t\t\t/^fe[89ab]/.test(value) ||\n\t\t\tvalue.startsWith(\"ff\")\n\t\t);\n\t}\n\treturn true;\n}\n\nexport function validateWebUrlSyntax(value: string): URL {\n\tlet url: URL;\n\ttry {\n\t\turl = new URL(value);\n\t} catch (error) {\n\t\tthrow new WebResearchError(\"URL_BLOCKED\", \"URL must be an absolute HTTP(S) address\", {\n\t\t\tcause: error,\n\t\t\tsanitizedUrl: sanitizeUrlForDiagnostics(value),\n\t\t});\n\t}\n\tif (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n\t\tthrow new WebResearchError(\"URL_BLOCKED\", `URL scheme ${url.protocol || \"(missing)\"} is not allowed`, {\n\t\t\tsanitizedUrl: sanitizeUrlForDiagnostics(value),\n\t\t});\n\t}\n\tif (url.username || url.password) {\n\t\tthrow new WebResearchError(\"URL_BLOCKED\", \"URLs containing credentials are not allowed\", {\n\t\t\tsanitizedUrl: sanitizeUrlForDiagnostics(value),\n\t\t});\n\t}\n\tconst hostname = url.hostname.toLowerCase().replace(/\\.$/, \"\");\n\tif (\n\t\thostname === \"localhost\" ||\n\t\thostname === \"metadata\" ||\n\t\thostname === \"metadata.google.internal\" ||\n\t\tINTERNAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))\n\t) {\n\t\tthrow new WebResearchError(\"URL_BLOCKED\", \"Internal hostnames are not allowed\", {\n\t\t\tsanitizedUrl: sanitizeUrlForDiagnostics(value),\n\t\t});\n\t}\n\treturn url;\n}\n\nexport async function resolveAndValidateWebUrl(\n\tvalue: string,\n\toptions: {\n\t\tresolver?: WebDnsResolver;\n\t\tadministrativeNetworkPolicy?: AdministrativeNetworkPolicy;\n\t\tsignal?: AbortSignal;\n\t} = {},\n): Promise<{ url: URL; addresses: LookupAddress[] }> {\n\tconst url = validateWebUrlSyntax(value);\n\tconst resolver: WebDnsResolver =\n\t\toptions.resolver ?? ((hostname, lookupOptions) => defaultLookup(hostname, lookupOptions));\n\tif (options.signal?.aborted) throw new WebResearchError(\"ABORTED\", \"URL resolution was aborted\");\n\tlet addresses: LookupAddress[];\n\ttry {\n\t\taddresses = await resolver(url.hostname, { all: true, verbatim: true });\n\t} catch (error) {\n\t\tthrow new WebResearchError(\"PROVIDER_UNAVAILABLE\", \"URL hostname could not be resolved\", {\n\t\t\tcause: error,\n\t\t\tsanitizedUrl: sanitizeUrlForDiagnostics(value),\n\t\t});\n\t}\n\tif (addresses.length === 0) {\n\t\tthrow new WebResearchError(\"PROVIDER_UNAVAILABLE\", \"URL hostname resolved to no addresses\", {\n\t\t\tsanitizedUrl: sanitizeUrlForDiagnostics(value),\n\t\t});\n\t}\n\tconst administrativelyAllowed =\n\t\toptions.administrativeNetworkPolicy?.allowPrivateNetwork === true &&\n\t\toptions.administrativeNetworkPolicy.allowedHosts.includes(url.hostname.toLowerCase());\n\tif (!administrativelyAllowed) {\n\t\tconst blocked = addresses.find((entry) => isBlockedWebAddress(entry.address));\n\t\tif (blocked) {\n\t\t\tthrow new WebResearchError(\"DNS_BLOCKED\", \"URL resolves to a non-public network address\", {\n\t\t\t\tsanitizedUrl: sanitizeUrlForDiagnostics(value),\n\t\t\t});\n\t\t}\n\t}\n\treturn { url, addresses };\n}\n"]}