UNPKG

fast-uri

Version:

Dependency-free RFC 3986 URI toolbox

708 lines (623 loc) • 23.6 kB
'use strict' const { isUUID, BYTE_HEX, percentEncodeNonAscii, nonSimpleMailtoDomain } = require('./utils') const URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu const supportedSchemeNames = /** @type {const} */ (['http', 'https', 'ws', 'wss', 'urn', 'urn:uuid', 'mailto']) /** @typedef {supportedSchemeNames[number]} SchemeName */ /** * @param {string} name * @returns {name is SchemeName} */ function isValidSchemeName (name) { return supportedSchemeNames.indexOf(/** @type {*} */ (name)) !== -1 } /** * @callback SchemeFn * @param {import('../types/index').URIComponent} component * @param {import('../types/index').Options} options * @returns {import('../types/index').URIComponent} */ /** * @typedef {Object} SchemeHandler * @property {SchemeName} scheme - The scheme name. * @property {boolean} [domainHost] - Indicates if the scheme supports domain hosts. * @property {SchemeFn} parse - Function to parse the URI component for this scheme. * @property {SchemeFn} serialize - Function to serialize the URI component for this scheme. * @property {boolean} [skipNormalize] - Indicates if normalization should be skipped for this scheme. * @property {boolean} [absolutePath] - Indicates if the scheme uses absolute paths. * @property {boolean} [unicodeSupport] - Indicates if the scheme supports Unicode. */ /** * @param {import('../types/index').URIComponent} wsComponent * @returns {boolean} */ function wsIsSecure (wsComponent) { if (wsComponent.secure === true) { return true } else if (wsComponent.secure === false) { return false } else if (wsComponent.scheme) { return ( wsComponent.scheme.length === 3 && (wsComponent.scheme[0] === 'w' || wsComponent.scheme[0] === 'W') && (wsComponent.scheme[1] === 's' || wsComponent.scheme[1] === 'S') && (wsComponent.scheme[2] === 's' || wsComponent.scheme[2] === 'S') ) } else { return false } } /** @type {SchemeFn} */ function httpParse (component) { if (!component.host) { component.error = component.error || 'HTTP URIs must have a host.' } return component } /** @type {SchemeFn} */ function httpSerialize (component) { const secure = String(component.scheme).toLowerCase() === 'https' // normalize the default port if (component.port === (secure ? 443 : 80) || component.port === '') { component.port = undefined } // normalize the empty path if (!component.path) { component.path = '/' } // NOTE: We do not parse query strings for HTTP URIs // as WWW Form Url Encoded query strings are part of the HTML4+ spec, // and not the HTTP spec. return component } /** @type {SchemeFn} */ function wsParse (wsComponent) { // indicate if the secure flag is set wsComponent.secure = wsIsSecure(wsComponent) // construct resouce name wsComponent.resourceName = (wsComponent.path || '/') + (wsComponent.query ? '?' + wsComponent.query : '') wsComponent.path = undefined wsComponent.query = undefined return wsComponent } /** @type {SchemeFn} */ function wsSerialize (wsComponent) { // normalize the default port if (wsComponent.port === (wsIsSecure(wsComponent) ? 443 : 80) || wsComponent.port === '') { wsComponent.port = undefined } // ensure scheme matches secure flag if (typeof wsComponent.secure === 'boolean') { wsComponent.scheme = (wsComponent.secure ? 'wss' : 'ws') wsComponent.secure = undefined } // reconstruct path from resource name if (wsComponent.resourceName) { const queryIndex = wsComponent.resourceName.indexOf('?') const path = queryIndex === -1 ? wsComponent.resourceName : wsComponent.resourceName.slice(0, queryIndex) wsComponent.path = (path && path !== '/' ? path : undefined) wsComponent.query = queryIndex === -1 ? undefined : wsComponent.resourceName.slice(queryIndex + 1) wsComponent.resourceName = undefined } // forbid fragment component wsComponent.fragment = undefined return wsComponent } /** @type {SchemeFn} */ function urnParse (urnComponent, options) { if (!urnComponent.path) { urnComponent.error = 'URN can not be parsed' return urnComponent } const matches = urnComponent.path.match(URN_REG) if (matches && matches[0] === urnComponent.path) { const scheme = options.scheme || urnComponent.scheme || 'urn' urnComponent.nid = matches[1].toLowerCase() urnComponent.nss = matches[2] const urnScheme = `${scheme}:${options.nid || urnComponent.nid}` const schemeHandler = getSchemeHandler(urnScheme) urnComponent.path = undefined if (schemeHandler) { urnComponent = schemeHandler.parse(urnComponent, options) } } else { urnComponent.error = urnComponent.error || 'URN can not be parsed.' } return urnComponent } /** @type {SchemeFn} */ function urnSerialize (urnComponent, options) { if (urnComponent.nid === undefined) { throw new Error('URN without nid cannot be serialized') } const scheme = options.scheme || urnComponent.scheme || 'urn' const nid = urnComponent.nid.toLowerCase() const urnScheme = `${scheme}:${options.nid || nid}` const schemeHandler = getSchemeHandler(urnScheme) if (schemeHandler) { urnComponent = schemeHandler.serialize(urnComponent, options) } const uriComponent = urnComponent const nss = urnComponent.nss uriComponent.path = `${nid || options.nid}:${nss}` options.skipEscape = true return uriComponent } /** @type {SchemeFn} */ function urnuuidParse (urnComponent, options) { const uuidComponent = urnComponent uuidComponent.uuid = uuidComponent.nss uuidComponent.nss = undefined if (!options.tolerant && (!uuidComponent.uuid || !isUUID(uuidComponent.uuid))) { uuidComponent.error = uuidComponent.error || 'UUID is not valid.' } return uuidComponent } /** @type {SchemeFn} */ function urnuuidSerialize (uuidComponent) { const urnComponent = uuidComponent // normalize UUID urnComponent.nss = (uuidComponent.uuid || '').toLowerCase() return urnComponent } const http = /** @type {SchemeHandler} */ ({ scheme: 'http', domainHost: true, parse: httpParse, serialize: httpSerialize }) const https = /** @type {SchemeHandler} */ ({ scheme: 'https', domainHost: http.domainHost, parse: httpParse, serialize: httpSerialize }) const ws = /** @type {SchemeHandler} */ ({ scheme: 'ws', domainHost: true, parse: wsParse, serialize: wsSerialize }) const wss = /** @type {SchemeHandler} */ ({ scheme: 'wss', domainHost: ws.domainHost, parse: ws.parse, serialize: ws.serialize }) const urn = /** @type {SchemeHandler} */ ({ scheme: 'urn', parse: urnParse, serialize: urnSerialize, skipNormalize: true, // The NSS is opaque to generic URI path processing. In particular, RFC 2141 // and RFC 8141 do not treat slash-delimited dot segments as equivalent. absolutePath: true }) const urnuuid = /** @type {SchemeHandler} */ ({ scheme: 'urn:uuid', parse: urnuuidParse, serialize: urnuuidSerialize, skipNormalize: true }) /** * Allow-sets for `encodeWithAllow`, each a `{ all, table, allowNonAscii }` * descriptor built by `allowSet`: * * - `LOCAL_PART` is the intersection of upstream uri-js's VCHAR and * NOT_PATH_NOSCHEME, minus ",": the serializer joins recipients with "," and * `mailtoParse` splits the path on it, so a comma left literal inside a local * part would silently become a recipient delimiter. * - `HFNAME` is the qchar allow-set used for header names/values. * - `DTEXT` is RFC 6068 dtext-no-obs restricted to characters that are also * safe in a URI path; `DTEXT_IRI` additionally permits non-ASCII for * `unicodeSupport`. */ const LOCAL_PART_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+=" const HFNAME_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+,;:@" const DTEXT_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+;:" /** * @typedef {Object} AllowSet * @property {RegExp} all - matches a whole string needing no encoding at all. * @property {Uint8Array} table - per-ASCII-code allow lookup for the slow path. * @property {boolean} allowNonAscii - pass non-ASCII code points through (IRI). */ /** * `all` deliberately excludes "%" and (for the IRI variant) surrogates, so any * input needing escape normalization or surrogate repair falls to the slow path. * * @param {string} chars * @param {boolean} [allowNonAscii=false] * @returns {AllowSet} */ function allowSet (chars, allowNonAscii = false) { const table = new Uint8Array(128) for (let i = 0; i < chars.length; i++) { table[chars.charCodeAt(i)] = 1 } const escaped = chars.replace(/[\\\]^-]/gu, '\\$&') const cls = allowNonAscii ? `[${escaped}]|[^\\0-\\x7F\\uD800-\\uDFFF]` : `[${escaped}]` return { all: new RegExp(`^(?:${cls})*$`, 'u'), table, allowNonAscii } } const LOCAL_PART = allowSet(LOCAL_PART_ALLOWED) const HFNAME = allowSet(HFNAME_ALLOWED) const DTEXT = allowSet(DTEXT_ALLOWED) const DTEXT_IRI = allowSet(DTEXT_ALLOWED, true) const HEX_PAIR = /^[\da-f]{2}$/iu const MAILTO_DOMAIN_LITERAL = /^\[[\x21-\x5A\x5E-\x7E]*\]$/u const MAILTO_DOMAIN_ERROR = 'URI mailto has an invalid recipient domain.' const MAILTO_AUTHORITY_ERROR = 'URI mailto must not have an authority component.' const HAS_SURROGATE = /[\uD800-\uDFFF]/u /** * @param {string} str * @returns {string} */ function decodeHex (str) { if (typeof str !== 'string' || str.indexOf('%') === -1) { return str } try { return decodeURIComponent(str) } catch { return str } } /** * Replaces lone surrogates with U+FFFD, leaving valid pairs intact. Only called * when `HAS_SURROGATE` matched, so the scan cost is not on the common path. * (`String.prototype.toWellFormed` would do this natively but is Node 20+.) * * @param {string} input * @returns {string} */ function replaceLoneSurrogates (input) { let result = '' for (let i = 0; i < input.length; i++) { const code = input.charCodeAt(i) if (code >= 0xD800 && code <= 0xDBFF && i + 1 < input.length) { const low = input.charCodeAt(i + 1) if (low >= 0xDC00 && low <= 0xDFFF) { result += input[i] + input[i + 1] i++ continue } } result += (code >= 0xD800 && code <= 0xDFFF) ? '\uFFFD' : input[i] } return result } /** * Percent-encodes everything outside `set`, preserving existing valid escapes * (uppercased) and repairing lone surrogates. * * ASCII uses `BYTE_HEX` rather than `encodeURIComponent`. That is equivalent * only because every character `encodeURIComponent` leaves unescaped * (`A-Za-z0-9-_.!~*'()`) is present in all of the allow-sets above, so no such * character ever reaches the encode branch. Re-check if a set is narrowed. * * @param {string} input * @param {AllowSet} set * @returns {string} */ function encodeWithAllow (input, set) { if (set.all.test(input)) { return input } const table = set.table let result = '' for (let i = 0; i < input.length; i++) { const code = input.charCodeAt(i) if (code < 0x80) { if (table[code] === 1) { result += input[i] } else if (code === 0x25 && i + 2 < input.length && HEX_PAIR.test(input.slice(i + 1, i + 3))) { result += '%' + input.slice(i + 1, i + 3).toUpperCase() i += 2 } else { result += BYTE_HEX[code] } continue } if (code < 0xD800 || code > 0xDFFF) { result += set.allowNonAscii ? input[i] : percentEncodeNonAscii(code) continue } if (code <= 0xDBFF && i + 1 < input.length) { const low = input.charCodeAt(i + 1) if (low >= 0xDC00 && low <= 0xDFFF) { result += set.allowNonAscii ? input[i] + input[i + 1] : percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00)) i++ continue } } result += set.allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD) } return result } /** * @param {string} domain * @param {import('../types/index').Options} [options] * @param {{error?:string}} [component] * @returns {string} */ function mailtoNormalizeDomain (domain, options, component) { const normalizedDomain = String(domain).toLowerCase() if (MAILTO_DOMAIN_LITERAL.test(normalizedDomain)) { return normalizedDomain } // `hostname` is the identity for these, so skip constructing a WHATWG URL. if (normalizedDomain !== '' && !nonSimpleMailtoDomain(normalizedDomain)) { return normalizedDomain } try { const parsedDomain = new URL('http://' + normalizedDomain) if ( parsedDomain.username || parsedDomain.password || parsedDomain.port || parsedDomain.pathname !== '/' || parsedDomain.search || parsedDomain.hash || !parsedDomain.hostname ) { throw new Error(MAILTO_DOMAIN_ERROR) } return options && options.unicodeSupport ? normalizedDomain : parsedDomain.hostname } catch { if (component) { component.error = component.error || MAILTO_DOMAIN_ERROR } return normalizedDomain } } /** * Percent-encodes everything in a recipient domain that is not `dtext-no-obs` * (RFC 6068). Serialize-only: `mailtoNormalizeDomain` returns the domain * verbatim when validation fails, and `MAILTO_DOMAIN_LITERAL` accepts the full * RFC 5321 dcontent range (which includes "?", "#", "&" and "/"), so without * this an attacker-supplied recipient could inject header fields or a fragment * into the serialized URI. `mailtoParse` deliberately reports the raw domain * instead, so it must not use this. * @param {string} domain * @param {import('../types/index').Options} [options] * @returns {string} */ function mailtoEncodeDomain (domain, options) { const set = options && options.unicodeSupport ? DTEXT_IRI : DTEXT // Keep the delimiters of a domain literal, encode its contents. if (domain.length > 1 && domain[0] === '[' && domain[domain.length - 1] === ']') { return '[' + encodeWithAllow(domain.slice(1, -1), set) + ']' } return encodeWithAllow(domain, set) } /** * @param {import('../types/index').URIComponent} component * @param {import('../types/index').Options} options * @returns {import('../types/index').URIComponent} */ function mailtoParse (component, options) { const mailtoComponent = component // The handler sets `skipNormalize`, so path and query arrive raw. Everything // here goes through `decodeHex`, which subsumes the generic normalizers -- // except that they also fold lone surrogates to U+FFFD, so do that here. let rawPath = mailtoComponent.path let rawQuery = mailtoComponent.query if (rawPath && HAS_SURROGATE.test(rawPath)) rawPath = replaceLoneSurrogates(rawPath) if (rawQuery && HAS_SURROGATE.test(rawQuery)) rawQuery = replaceLoneSurrogates(rawQuery) const to = rawPath ? (rawPath.indexOf(',') === -1 ? [rawPath] : rawPath.split(',')) : [] mailtoComponent.path = undefined if (rawQuery) { // Null prototype: header names come from untrusted input, so `headers[name]` // must not resolve to inherited members ("constructor", "toString", ...), // and "__proto__" has to land as a plain own property instead of hitting // Object.prototype's setter and being silently dropped. Allocated lazily so // the common subject/body-only URI does not pay for it. /** @type {Record<string,string>|null} */ let headers = null let start = 0 while (start <= rawQuery.length) { let end = rawQuery.indexOf('&', start) if (end === -1) end = rawQuery.length const token = rawQuery.slice(start, end) start = end + 1 const eqIdx = token.indexOf('=') if (eqIdx !== token.lastIndexOf('=')) { mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.' continue } const name = decodeHex(eqIdx === -1 ? token : token.slice(0, eqIdx)) const normalizedName = name.toLowerCase() const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1) if (normalizedName === 'to') { const addrs = value.split(',') for (let j = 0; j < addrs.length; j++) to.push(addrs[j]) continue } if (normalizedName === 'subject') { mailtoComponent.subject = decodeHex(value) continue } if (normalizedName === 'body') { mailtoComponent.body = decodeHex(value) continue } if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null)) headers[name] = decodeHex(value) } if (headers !== null) mailtoComponent.headers = headers } mailtoComponent.query = undefined // RFC 6068 defines no authority for the `mailto` scheme. A `mailto://` input // that retains an authority and also carries recipients (from the path or a // `to=` hfield) folds a leading "/" into the recipient local part on the // next parse; the generic serializer then reinserts a "/" before that path // on every pass, so serialize(parse(x)) is not a fixpoint and a // renormalize-to-fixpoint loop accretes one "%2F" per pass. Drop the // retained authority so the parse -> serialize round trip is stable. A bare // `mailto://host` with no recipients is left as-is: it is idempotent and host // normalization for skip-normalize schemes stays intact. if ( to.length > 0 && (mailtoComponent.userinfo !== undefined || mailtoComponent.host !== undefined || mailtoComponent.port !== undefined) ) { mailtoComponent.userinfo = undefined mailtoComponent.host = undefined mailtoComponent.port = undefined mailtoComponent.error = mailtoComponent.error || MAILTO_AUTHORITY_ERROR } for (let i = 0; i < to.length; i++) { const rawAddr = to[i] const atIdx = rawAddr.lastIndexOf('@') if (atIdx < 0) { mailtoComponent.error = mailtoComponent.error || MAILTO_DOMAIN_ERROR to[i] = decodeHex(rawAddr) continue } const local = decodeHex(rawAddr.slice(0, atIdx)) const domain = mailtoNormalizeDomain(decodeHex(rawAddr.slice(atIdx + 1)), options, mailtoComponent) to[i] = local + '@' + domain } if (to.length) mailtoComponent.to = to return mailtoComponent } /** * Encodes a decoded mailto header name. Unlike `encodeWithAllow`, a percent * sign is always data here, even when followed by two hex digits. Preserving * such a sequence as an existing escape would decode the name again on the * next parse and could turn a generic header into `to`, `subject`, or `body`. * @param {string} name * @returns {string} */ function mailtoEncodeHeaderName (name) { return encodeWithAllow(name.replace(/%/gu, '%25'), HFNAME) } /** * @param {import('../types/index').URIComponent} component * @param {import('../types/index').Options} options * @returns {import('../types/index').URIComponent} */ function mailtoSerialize (component, options) { const mailtoComponent = component const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : [] const sourceHeaders = mailtoComponent.headers && typeof mailtoComponent.headers === 'object' ? Object.assign(Object.create(null), mailtoComponent.headers) : Object.create(null) const headers = Object.create(null) // Header field names are case-insensitive. Canonicalize the fields with // dedicated component properties so callers cannot smuggle a second spelling // through `headers`. As during parsing, all `to` fields are accumulated; // repeated subject/body fields use the last value. for (const name in sourceHeaders) { const normalizedName = name.toLowerCase() if (normalizedName === 'to') { const addrs = String(sourceHeaders[name]).split(',') for (let i = 0; i < addrs.length; i++) to.push(addrs[i]) } else if (normalizedName === 'subject' || normalizedName === 'body') { headers[normalizedName] = sourceHeaders[name] } else { headers[name] = sourceHeaders[name] } } if (mailtoComponent.subject !== undefined) headers.subject = mailtoComponent.subject if (mailtoComponent.body !== undefined) headers.body = mailtoComponent.body mailtoComponent.headers = headers if (to.length) { // RFC 6068 defines no authority for the `mailto` scheme. If an // application constructs a component with both an authority and recipients, // drop the authority before the generic serializer can insert a "/" path // separator that becomes part of the first recipient on the next parse. mailtoComponent.userinfo = undefined mailtoComponent.host = undefined mailtoComponent.port = undefined for (let i = 0; i < to.length; i++) { const addr = String(to[i]) const atIdx = addr.lastIndexOf('@') const rawLocal = atIdx >= 0 ? addr.slice(0, atIdx) : addr const rawDomain = atIdx >= 0 ? addr.slice(atIdx + 1) : '' const local = encodeWithAllow(rawLocal, LOCAL_PART) const decodedDomain = decodeHex(rawDomain) // `component` is intentionally not passed: index.js's `serialize` writes // the error onto a private copy and returns only a string, so an error // recorded here would be unreachable. Safety comes from encoding instead. const normalizedDomain = mailtoNormalizeDomain(decodedDomain, options) to[i] = local + '@' + mailtoEncodeDomain(normalizedDomain, options) } // Every recipient is now fully encoded and only the joining commas are // literal, so bypass index.js's generic encoder, whose allow-set is // narrower than upstream's and would over-encode. mailtoComponent.path = to.join(',') options.skipEscape = true } else { // No recipients: drop any caller-supplied path, matching upstream uri-js. // `to` is the only recipient source, and `mailtoParse` always moves the // path into it, so a mailto component never legitimately carries one. mailtoComponent.path = undefined } // `headers` has a null prototype, so every enumerable key is an own property // and no hasOwnProperty guard is needed. let query = '' let count = 0 for (const name in headers) { if (count++ !== 0) query += '&' query += mailtoEncodeHeaderName(name) + '=' + encodeWithAllow(String(headers[name]), HFNAME) } if (count !== 0) { mailtoComponent.query = query } else { mailtoComponent.headers = undefined } return mailtoComponent } const mailto = /** @type {SchemeHandler} */ ({ scheme: 'mailto', parse: mailtoParse, serialize: mailtoSerialize, domainHost: false, unicodeSupport: true, // `mailtoParse` re-derives every component from the raw path/query via // `decodeHex`, which subsumes the generic normalizers, so running them first // is wasted work (~28% of parsing a URI with header fields). skipNormalize: true, // A recipient list has no dot segments to remove, and `removeDotSegments` // would rewrite a "./"-prefixed local part. absolutePath: true }) const SCHEMES = /** @type {Record<SchemeName, SchemeHandler>} */ ({ http, https, ws, wss, urn, 'urn:uuid': urnuuid, mailto }) Object.setPrototypeOf(SCHEMES, null) /** * @param {string|undefined} scheme * @returns {SchemeHandler|undefined} */ function getSchemeHandler (scheme) { return ( scheme && ( SCHEMES[/** @type {SchemeName} */ (scheme)] || SCHEMES[/** @type {SchemeName} */(scheme.toLowerCase())]) ) || undefined } module.exports = { wsIsSecure, SCHEMES, isValidSchemeName, getSchemeHandler, }