separate validateClientIdentifier from fetchClientIdentifier, add validateProfile
[squeep-indieauth-helper] / lib / communication.js
index 540eaafa14623ca251a2a7b8d1267bf26a57105b..0116548d6539eb70fe229df71aee35cf86305034 100644 (file)
@@ -10,11 +10,19 @@ const { performance } = require('perf_hooks');
 const { randomBytes, createHash } = require('crypto');
 const { promisify } = require('util');
 const randomBytesAsync = promisify(randomBytes);
+const { Address4, Address6 } = require('ip-address');
+const dns = require('dns');
+dns.lookupAsync = dns.lookupAsync || promisify(dns.lookup);
 const common = require('./common');
 const Enum = require('./enum');
+const { ValidationError } = require('./errors');
 
 const _fileScope  = common.fileScope(__filename);
 
+const noDotPathRE = /(\/\.\/|\/\.\.\/)/;
+const v6HostRE = /\[[0-9a-f:]+\]/;
+const loopback4 = new Address4('127.0.0.0/8');
+
 class Communication {
   /**
    * @param {Console} logger
@@ -327,6 +335,211 @@ class Communication {
   }
 
 
+  /**
+   * Validate a url has a specific schema.
+   * @param {URL} urlObj
+   * @param {String[]} validSchemes
+   */
+  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
+   * @param {String[]} disallowed
+   */
+  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
+   */
+  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
+   * @param {Boolean} allowLoopback
+   */
+  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) {
+        /* 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.lookupAsync(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
+   * @param {Object} validationOptions
+   * @param {Boolean} validationOptions.allowLoopback
+   */
+  async validateProfile(url, validationOptions) {
+    const _scope = _fileScope('validateProfile');
+    const errorScope = 'invalid profile url';
+
+    const options = Object.assign({}, {
+      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);
+      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
+   * @param {Object} validationOptions
+   * @param {Boolean} validationOptions.allowLoopback
+   * @param {Boolean} validationOptions.resolveHostname
+   * @returns {URL}
+   */
+  async validateClientIdentifier(url, validationOptions) {
+    const _scope = _fileScope('validateClientIdentifier');
+    const errorScope = 'invalid client identifier url';
+
+    const options = Object.assign({}, {
+      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} ClientIdentifierData
    * @property {Object} rels - keyed by relation to array of uris
@@ -334,10 +547,24 @@ class Communication {
    */
   /**
    * Retrieve and parse client identifier endpoint data.
+   * N.B. Assumes urlObj has passed validateClientIdentifier.
    * @param {URL} urlObj
    * @returns {ClientIdentifierData|undefined} mf2 data filtered for h-app items, or undefined if url could not be fetched
    */
   async fetchClientIdentifier(urlObj) {
+    const _scope = _fileScope('fetchClientIdentifier');
+
+    // Loopback address will eschew client fetch, return empty data.
+    const isLoopbackResult = {
+      rels: {},
+      items: [],
+    };
+
+    // Set by validation method in case of loopback ip hostname
+    if (urlObj.isLoopback) {
+      return isLoopbackResult;
+    }
+
     const mfData = await this.fetchMicroformat(urlObj);
     if (!mfData) {
       return undefined;