X-Git-Url: https://git.squeep.com/?a=blobdiff_plain;f=lib%2Fcommunication.js;h=0116548d6539eb70fe229df71aee35cf86305034;hb=cf9590ecbcd4b0a7c01f153cacade619518f84f0;hp=51f5b430c9fc160687c07111f1fe4727e759800b;hpb=e648aedc1c912cd07da0b1dad7be3910248b25c9;p=squeep-indieauth-helper diff --git a/lib/communication.js b/lib/communication.js index 51f5b43..0116548 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -2,6 +2,7 @@ const axios = require('axios'); const { mf2 } = require('microformats-parser'); +const { base64ToBase64URL } = require('@squeep/base64url'); const { parse: parseLinkHeader } = require('@squeep/web-linking'); const { Iconv } = require('iconv'); const { version: packageVersion, name: packageName } = require('../package.json'); @@ -9,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 @@ -46,7 +55,7 @@ class Communication { static _challengeFromVerifier(verifier) { const hash = createHash('sha256'); hash.update(verifier); - return common.base64ToBase64URL(hash.digest('base64')); + return base64ToBase64URL(hash.digest('base64')); } /** @@ -61,7 +70,7 @@ class Communication { const bufferLength = Math.floor(length * 3 / 4); const randomBuffer = await randomBytesAsync(bufferLength); - const verifier = common.base64ToBase64URL(randomBuffer.toString('base64')); + const verifier = base64ToBase64URL(randomBuffer.toString('base64')); const challenge = Communication._challengeFromVerifier(verifier); @@ -291,6 +300,246 @@ class Communication { } + /** + * Retrieve and parse JSON. + * N.B. this absorbs any errors! + * @param {URL} urlObj + * @returns {Object} + */ + async fetchJSON(urlObj) { + const _scope = _fileScope('fetchJSON'); + const logInfoData = { + url: urlObj.href, + response: undefined, + }; + let response; + try { + const fetchJSONConfig = Communication._axiosConfig('GET', urlObj, undefined, undefined, { + [Enum.Header.Accept]: [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', '), + }); + response = await this.axios(fetchJSONConfig); + } catch (e) { + this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData }); + return; + } + logInfoData.response = common.axiosResponseLogData(response); + + let data; + try { + data = JSON.parse(response.data); + } catch (e) { + this.logger.error(_scope, 'json parsing failed', { error: e, ...logInfoData }); + } + + return data; + } + + + /** + * 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 @@ -298,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; @@ -336,23 +599,45 @@ class Communication { * @property {String} photo * @property {String} url * @property {String} email - * @property {String} authorizationEndpoint - * @property {String} tokenEndpoint + * @property {String} authorizationEndpoint - deprecated, backwards compatibility for 20201126 spec + * @property {String} tokenEndpoint - deprecated, backwards compatibility for 20201126 spec + * @property {String} indieauthMetadata authorization server metadata endpoint + * @property {Object} metadata - authorization server metadata for profile + * @property {String} metadata.issuer + * @property {String} metadata.authorizationEndpoint + * @property {String} metadata.tokenEndpoint + * @property {String} metadata.introspectionEndpoint + * @property {String} metadata.introspectionEndpointAuthMethodsSupported + * @property {String} metadata.revocationEndpoint + * @property {String} metadata.revocationEndpointAuthMethodsSupported + * @property {String} metadata.scopesSupported + * @property {String} metadata.responseTypesSupported + * @property {String} metadata.grantTypesSupported + * @property {String} metadata.serviceDocumentation + * @property {String} metadata.codeChallengeMethodsSupported + * @property {String} metadata.authorizationResponseIssParameterSupported + * @property {String} metadata.userinfoEndpoint */ /** - * Fetch all the microformat data from url, but only return the relevant h-card profile information. + * Fetch the relevant microformat data from profile url h-card information, + * and authorization server metadata. * @param {URL} urlObj * @returns {ProfileData} mf2 data filtered for select fields from h-card */ async fetchProfile(urlObj) { + const _scope = _fileScope('fetchProfile'); + const mfData = await this.fetchMicroformat(urlObj); const profile = { name: undefined, photo: undefined, url: undefined, email: undefined, + metadata: {}, }; + // Locate h-card mf2 items with url field matching profile url, + // and populate profile fields with first-encountered card values. if (mfData && 'items' in mfData) { const hCards = mfData.items.filter((item) => item.type && item.type.includes('h-card') && @@ -365,15 +650,67 @@ class Communication { }); }); } + + // Populate legacy mf2 fields from relation links. + // These will be overwritten if they also exist in server metadata. Object.entries({ - authorizationEndpoint: 'authorization_endpoint', - tokenEndpoint: 'token_endpoint', + authorizationEndpoint: 'authorization_endpoint', // backwards compatibility + tokenEndpoint: 'token_endpoint', // backwards compatibility }).forEach(([p, r]) => { if (mfData && r in mfData.rels) { - profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection + profile.metadata[p] = profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection } }); + // Set metadata field. + if (mfData && 'indieauth-metadata' in mfData.rels) { + profile.indieauthMetadata = mfData.rels['indieauth-metadata'][0]; + } + + // Attempt to populate metadata from authorization server. + if (profile.indieauthMetadata) { + let mdURL; + try { + mdURL = new URL(profile.indieauthMetadata); + } catch (e) /* istanbul ignore next */ { + this.logger.error(_scope, 'invalid authorization server metadata url', { profile }); + } + /* istanbul ignore else */ + if (mdURL) { + const metadataResponse = await this.fetchJSON(mdURL); + if (metadataResponse) { + // Map snake_case fields to camelCase. + Object.entries({ + issuer: 'issuer', + authorizationEndpoint: 'authorization_endpoint', + tokenEndpoint: 'token_endpoint', + introspectionEndpoint: 'introspection_endpoint', + introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported', + revocationEndpoint: 'revocation_endpoint', + revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported', + scopesSupported: 'scopes_supported', + responseTypesSupported: 'response_types_supported', + grantTypesSupported: 'grant_types_supported', + serviceDocumentation: 'service_documentation', + codeChallengeMethodsSupported: 'code_challenge_methods_supported', + authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported', + userinfoEndpoint: 'userinfo_endpoint', + }).forEach(([c, s]) => { + if (s in metadataResponse) { + profile.metadata[c] = metadataResponse[s]; // eslint-disable-line security/detect-object-injection + } + }); + + // Populate legacy profile fields. + ['authorizationEndpoint', 'tokenEndpoint'].forEach((f) => { + if (f in profile.metadata) { + profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection + } + }); + } + } + } + return profile; }