+ * Retrieve and parse JSON.
+ * N.B. this absorbs any errors!
+ * @param {URL} urlObj url
+ * @returns {Promise<object>} parsed json
+ */
+ async fetchJSON(urlObj) {
+ const _scope = _fileScope('fetchJSON');
+ const logInfoData = {
+ url: urlObj.href,
+ response: undefined,
+ };
+ let response;
+ try {
+ const fetchJSONConfig = {
+ method: 'GET',
+ url: urlObj,
+ headers: {
+ [Enum.Header.Accept]: this._jsonAccept,
+ },
+ responseType: 'json',
+ };
+ response = await this.got(fetchJSONConfig);
+ } catch (e) {
+ this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
+ return;
+ }
+
+ return response.body;
+ }
+
+
+ /**
+ * Validate a url has a specific schema.
+ * @param {URL} urlObj url
+ * @param {string[]} validSchemes url schemes
+ */
+ static _urlValidScheme(urlObj, validSchemes = ['http:', 'https:']) {
+ if (!validSchemes.includes(urlObj.protocol)) {
+ throw new ValidationError(`unsupported url scheme '${urlObj.protocol}'`);
+ }
+ }
+
+
+ /**
+ * Validate a url does not include some components.
+ * @param {URL} urlObj url
+ * @param {string[]} disallowed component names
+ */
+ static _urlPartsDisallowed(urlObj, disallowed) {
+ disallowed.forEach((part) => {
+ if (urlObj[part]) { // eslint-disable-line security/detect-object-injection
+ throw new ValidationError(`url cannot contain ${common.properURLComponentName(part)}`);
+ }
+ });
+ }
+
+
+ /**
+ * Validate a url does not have relative path.
+ * @param {string} url url
+ */
+ static _urlPathNoDots(url) {
+ if (noDotPathRE.test(url)) {
+ throw new ValidationError('relative path segment not valid');
+ }
+ }
+
+
+ /**
+ * Validate a url does not have a hostname which is an ip address.
+ * N.B. Sets isLoopback on urlObj
+ * @param {URL} urlObj url
+ * @param {boolean} allowLoopback allow loopback ip
+ * @param {boolean} resolveHostname resolve hostname for checks
+ * @returns {Promise<void>}
+ */
+ static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) {
+ let address;
+ if (v6HostRE.test(urlObj.hostname)) {
+ /**
+ * We do not need to worry about the Address6() failing to parse,
+ * as if it looks like an ipv6 addr but is not valid, the URL()
+ * call would already have failed.
+ */
+ address = new Address6(urlObj.hostname.slice(1, urlObj.hostname.length - 1));
+ /* Succeeded parsing as ipv6, reject unless loopback */
+ urlObj.isLoopback = address.isLoopback();
+ } else {
+ try {
+ address = new Address4(urlObj.hostname);
+ /* Succeeded parsing as ipv4, reject unless loopback */
+ urlObj.isLoopback = address.isInSubnet(loopback4);
+ } catch (e) { // eslint-disable-line no-unused-vars
+ /* Did not parse as ip, carry on */
+ }
+ }
+
+ if (resolveHostname && !urlObj.isLoopback) {
+ /**
+ * Resolve hostname to check for localhost.
+ * This is more complicated due to SSRF mitigation:
+ * If the hostname does not end with a ., we also resolve that,
+ * and complain if the two resolutions do not match, assuming
+ * malicious intent for the server to resolve a local record.
+ */
+ const hostnames = [urlObj.hostname];
+ if (!urlObj.hostname.endsWith('.')) {
+ hostnames.push(urlObj.hostname + '.');
+ }
+ const settledResolutions = await Promise.allSettled(hostnames.map((hostname) => dns.promises.lookup(hostname, {
+ all: true,
+ verbatim: true,
+ })));
+ // If any resolution failed, bail.
+ if (settledResolutions
+ .map((resolution) => resolution.status)
+ .includes('rejected')) {
+ throw new ValidationError('could not resolve hostname');
+ }
+
+ // Extract each resolution value, array of {address,family}
+ const resolutions = settledResolutions.map((resolution) => resolution.value);
+
+ // If there were two resolutions, ensure they returned identical results.
+ if (resolutions.length > 1) {
+ // Create set of addresses for each resolution
+ const addressSets = resolutions.map((addrs) => {
+ return new Set((addrs || []).map((a) => a.address));
+ });
+ const differences = common.setSymmetricDifference(...addressSets);
+ if (differences.size) {
+ throw new ValidationError('inconsistent hostname resolution');
+ }
+ }
+ const resolvedHost = resolutions[0] || [];
+
+ // Persist the loopback state
+ urlObj.isLoopback = resolvedHost.reduce((acc, resolved) => {
+ let addr;
+ switch (resolved.family) {
+ case 4:
+ addr = new Address4(resolved.address);
+ return acc || addr.isInSubnet(loopback4);
+ case 6:
+ addr = new Address6(resolved.address);
+ return acc || addr.isLoopback();
+ default:
+ return acc;
+ }
+ }, false);
+ }
+
+ if (address
+ && (!urlObj.isLoopback || !allowLoopback)) {
+ throw new ValidationError('hostname cannot be IP');
+ }
+ }
+
+
+ /**
+ * Ensure a url meets the requirements to be a profile uri.
+ * @param {string} url url
+ * @param {object} validationOptions options
+ * @param {boolean=} validationOptions.allowLoopback default is false, following spec
+ * @param {boolean=} validationOptions.resolveHostname default is false, following spec
+ * @returns {Promise<URL>} validated url
+ */
+ async validateProfile(url, validationOptions) {
+ const _scope = _fileScope('validateProfile');
+ const errorScope = 'invalid profile url';
+
+ const options = {
+ allowLoopback: false,
+ resolveHostname: false,
+ ...validationOptions,
+ };
+
+ let profile;
+ try {
+ profile = new URL(url);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to parse url', { error: e, url });
+ throw new ValidationError(`${errorScope}: unparsable`);
+ }
+ profile.isLoopback = false;
+
+ try {
+ Communication._urlValidScheme(profile);
+ Communication._urlPartsDisallowed(profile, ['hash', 'username', 'password', 'port']);
+ Communication._urlPathNoDots(url);
+ await Communication._urlNamedHost(profile, options.allowLoopback, options.resolveHostname);
+ } catch (e) {
+ this.logger.debug(_scope, 'profile url not valid', { url, error: e });
+ throw new ValidationError(`${errorScope}: ${e.message}`);
+ }
+
+ return profile;
+ }
+
+
+ /**
+ * Ensure a url meets the requirements to be a client identifier.
+ * Sets 'isLoopback' on returned URL object to true if hostname is - or resolves to - a loopback ip.
+ * @param {string} url url
+ * @param {object} validationOptions options
+ * @param {boolean=} validationOptions.allowLoopback default is true, following spec
+ * @param {boolean=} validationOptions.resolveHostname default is true, following spec
+ * @returns {Promise<URL>} validated client url
+ */
+ async validateClientIdentifier(url, validationOptions) {
+ const _scope = _fileScope('validateClientIdentifier');
+ const errorScope = 'invalid client identifier url';
+
+ const options = {
+ allowLoopback: true,
+ resolveHostname: true,
+ ...validationOptions,
+ };
+
+ let clientId;
+ try {
+ clientId = new URL(url);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to parse url', { error: e, url });
+ throw new ValidationError('invalid client identifier url: unparsable');
+ }
+ clientId.isLoopback = false;
+
+ try {
+ Communication._urlValidScheme(clientId);
+ Communication._urlPartsDisallowed(clientId, ['hash', 'username', 'password']);
+ Communication._urlPathNoDots(url);
+ await Communication._urlNamedHost(clientId, options.allowLoopback, options.resolveHostname);
+ } catch (e) {
+ this.logger.debug(_scope, 'client identifier url not valid', { url, error: e });
+ throw new ValidationError(`${errorScope}: ${e.message}`);
+ }
+
+ return clientId;
+ }
+
+
+ /**
+ * @typedef {object} HAppData
+ * @property {string[]=} type list of item types
+ * @property {object=} properties map of item properties
+ */
+ /**
+ * @typedef {object} ClientIdentifierData
+ * @property {object} rels keyed by relation to array of uris
+ * @property {HAppData[]} items h-app data