policy.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. /**
  2. * URL validation and content-type classification for the local HTTP(S) fetch
  3. * provider — the pure, network-free half. The provider's `fetch()` composes
  4. * these with transport (redirect following, byte caps, decoding).
  5. *
  6. * @module @deepseek-ai/dsh-web-fetch-http/policy
  7. */
  8. import { WebError } from '@deepseek-ai/dsh-web'
  9. /** The body kinds this provider decodes. */
  10. export type FetchableKind = 'html' | 'text'
  11. /**
  12. * Validate a request URL against the basic transport hygiene the provider
  13. * enforces before any network access: http(s) only, no embedded credentials,
  14. * bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise.
  15. * (SSRF / private-network blocking is deferred — see the package Agent Note.)
  16. *
  17. * @param input - the raw URL string from the fetch request.
  18. * @param maxUrlLength - inclusive upper bound on `input`'s length.
  19. * @returns the parsed `URL`.
  20. */
  21. export function validateFetchUrl(input: string, maxUrlLength: number): URL {
  22. if (input.length > maxUrlLength) {
  23. throw new WebError(`URL exceeds the maximum length of ${maxUrlLength}`, 'WEB_INVALID_URL')
  24. }
  25. let url: URL
  26. try {
  27. url = new URL(input)
  28. } catch (error: unknown) {
  29. throw new WebError(`invalid URL: ${input}`, 'WEB_INVALID_URL', { cause: error })
  30. }
  31. if (url.protocol !== 'http:' && url.protocol !== 'https:') {
  32. throw new WebError(`unsupported URL scheme "${url.protocol}" (only http and https are allowed)`, 'WEB_INVALID_URL')
  33. }
  34. if (url.username.length > 0 || url.password.length > 0) {
  35. throw new WebError('credentials in URLs are not allowed', 'WEB_BLOCKED_URL')
  36. }
  37. return url
  38. }
  39. /**
  40. * Two URLs are same-origin when scheme, hostname, and port match. A redirect
  41. * that crosses origins is refused so each new origin requires a fresh tool call
  42. * (and thus a fresh provider/permission decision).
  43. *
  44. * @param a - one of the two URLs to compare.
  45. * @param b - the other URL to compare.
  46. * @returns true when `a` and `b` share scheme, hostname, and port.
  47. */
  48. export function isSameOrigin(a: URL, b: URL): boolean {
  49. return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port
  50. }
  51. /**
  52. * Classify a response `Content-Type` into a decodable body kind, or `undefined`
  53. * for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml`
  54. * are `html`; other `text/*` plus a few structured text types are `text`.
  55. *
  56. * @param contentType - the raw `Content-Type` header, or `null` when the
  57. * response carries none (unsupported).
  58. * @returns the decodable kind, or `undefined` for an unsupported type.
  59. */
  60. export function classifyContentType(contentType: string | null): FetchableKind | undefined {
  61. const mime = (contentType ?? '').replace(/;.*$/s, '').trim().toLowerCase()
  62. if (mime === 'text/html' || mime === 'application/xhtml+xml') return 'html'
  63. if (mime.startsWith('text/')) return 'text'
  64. if (mime === 'application/json' || mime === 'application/xml' || mime.endsWith('+json') || mime.endsWith('+xml')) return 'text'
  65. return undefined
  66. }
  67. /**
  68. * Extract the `charset` parameter from a response `Content-Type`, lower-cased,
  69. * or `undefined` when absent. The provider feeds this label to `TextDecoder`
  70. * so a non-UTF-8 response is decoded with its declared encoding rather than
  71. * silently mangled into replacement characters.
  72. *
  73. * @param contentType - the raw `Content-Type` header, or `null` when the
  74. * response carries none.
  75. * @returns the lower-cased charset label, or `undefined` when none is declared.
  76. */
  77. export function parseCharset(contentType: string | null): string | undefined {
  78. const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? '')
  79. return match?.[1]?.trim().toLowerCase()
  80. }
  81. /**
  82. * Build a `TextDecoder` for the declared charset, falling back to UTF-8 when
  83. * none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when
  84. * the label is present but not a charset `TextDecoder` recognizes — better to
  85. * fail loudly than return mojibake.
  86. *
  87. * @param charset - the declared charset label (from {@link parseCharset}), or
  88. * `undefined` to default to UTF-8.
  89. * @returns a decoder for the declared (or defaulted) encoding.
  90. */
  91. export function decoderForCharset(charset: string | undefined): TextDecoder {
  92. if (charset === undefined) return new TextDecoder('utf-8')
  93. try {
  94. return new TextDecoder(charset)
  95. } catch (error: unknown) {
  96. throw new WebError(`unsupported charset "${charset}"`, 'WEB_UNSUPPORTED_CONTENT_TYPE', { cause: error })
  97. }
  98. }