UNPKG

@datadome/node-core

Version:

Core package for server-side modules using Node.js

510 lines (447 loc) 18.6 kB
/** * Utilities for DataDome modules. * @module @datadome/node-core/utils */ /// <reference path="./_typedefs.js" /> const os = require('os'); const { PassThrough } = require('stream'); const limits = require('./limits.js'); module.exports = { getClientId, getDataDomeRequestHeaders, isGraphQLDataAvailable, collectGraphQL, getRequestMetadata, mergeDataDomeRequestHeaders, mergeDataDomeResponseHeaders, addNonceToResponseBody, }; /** * Return the client ID from the `datadome` cookie. * @param {string} cookies - Raw value of a `Cookie` request header. * @returns {clientId: null|string} */ function getClientId(cookies) { let clientId = null; if (cookies) { const map = parseCookieString(cookies); const datadomeCookie = map.get('datadome'); if (datadomeCookie != null) { clientId = datadomeCookie; } } return clientId; } /** * Extract enriched headers for client requests. * @param {ServerResponse|null} apiResponse - A response from the DataDome API. * @returns {object.<string, string>|null} Header names and values as listed by the `x-datadome-request-headers` header in the API response. */ function getDataDomeRequestHeaders(apiResponse) { if (apiResponse == null || apiResponse.headers == null) { return null; } /** @type string */ const ddHeaders = apiResponse.headers['x-datadome-request-headers']; if (ddHeaders == null) { return null; } const headerPairs = ddHeaders .split(' ') .map((name) => name.toLowerCase()) .filter((name) => apiResponse.headers[name] != null) .map((name) => [name, apiResponse.headers[name]]); return Object.fromEntries(headerPairs); } /** * Determine if the incoming request is a GraphQL request * @param {Object} request * @param {URL} request.url - The URL of the request. * @param {string} request.method - The HTTP method of the request. * @param {boolean} request.bodyExists - Indicate if the request body is present. * @param {string} request.contentType - The Content-Type of the request. * @returns {boolean} - Returns `true` if the request is likely a GraphQL request, `false` otherwise. */ function isGraphQLRequest({ url, method, bodyExists, contentType }) { if (method === 'POST' && bodyExists == true && contentType.includes('application/json')) { return url.pathname.toLowerCase().includes('graphql'); } return false; } /** * Returns true if at least one GraphQL operation was detected. * @param {GraphQLData} graphQLData - GraphQL properties extracted from a request * @returns {boolean} true if the object contains GraphQL data */ function isGraphQLDataAvailable(graphQLData) { return graphQLData != null && graphQLData['count'] > 0; } /** * Fetch the GraphQL query from the query parameters. * @param {URL} fullUrl * @returns {string|null} */ function getGraphQLQueryStringFromQueryParams(fullUrl) { return fullUrl.searchParams.get('query'); } /** * Fetch the GraphQL query from the body. * @param {IncomingMessage} request * @param {number} maximumBodySize * @returns {Promise<string | null>} */ async function getGraphQLQueryStringFromBody(request, maximumBodySize) { // Create a PassThrough stream to read the body without consuming it const bodyStream = new PassThrough(); request.pipe(bodyStream); // Read the body chunk by chunk const regex = /"query"\s*:\s*("(?:query|mutation|subscription)?\s*(?:[A-Za-z_][A-Za-z0-9_]*)?\s*[{(].*)/; const readSize = 1024; let body = ''; let totalReadSize = 0; return new Promise((resolve, reject) => { bodyStream.on('readable', () => { let chunk; while ((chunk = bodyStream.read(readSize)) !== null) { // Update the total size of the body read so far totalReadSize += chunk.length; if (totalReadSize > maximumBodySize) { resolve(null); return; } body += chunk.toString(); // Keep only the last 2 chunks (up to 2 * readSize) if (body.length > 2 * readSize) { body = body.slice(-2 * readSize); } const match = body.match(regex); if (match !== null && match.length > 0) { resolve(match[1]); return; } } }); bodyStream.on('end', () => { resolve(null); }); bodyStream.on('error', (err) => { reject(err); }); }); } /** * Collect the GraphQL information from the request * @param {IncomingMessage} request * @param {URL} fullUrl * @param {number} maximumBodySize * @returns {GraphQLData} */ async function collectGraphQL(request, fullUrl, maximumBodySize) { const result = { name: '', type: 'query', count: 0, }; let queryString = getGraphQLQueryStringFromQueryParams(fullUrl); if (queryString == null) { queryString = await getGraphQLQueryStringFromBody(request, maximumBodySize); } if (queryString == null) { return result; } const regex = /(?<operationType>query|mutation|subscription)\s*(?<operationName>[A-Za-z_][A-Za-z0-9_]*)?\s*[({@]/gm; const matches = Array.from(queryString.matchAll(regex)); let matchLength = matches.length; if (matchLength > 0) { result['type'] = matches[0].groups.operationType ?? 'query'; result['name'] = matches[0].groups.operationName ?? ''; } else { const shorthandSyntaxRegex = /"(?<operationType>(?:query|mutation|subscription))?\s*(?<operationName>[A-Za-z_][A-Za-z0-9_]*)?\s*[({@]/gm; const shorthandSyntaxMatches = Array.from(queryString.matchAll(shorthandSyntaxRegex)); matchLength = shorthandSyntaxMatches.length; } result['count'] = matchLength; return result; } /** * See https://docs.datadome.co/reference/validate-request for details. * @async * @param {IncomingMessage|null} request - An HTTP request to pick the data from. * @param {MetadataParameters|null} parameters - Parameters coming from the module. * @param {MetadataHandlers} [handlers] - Functions to execute to get a specific payload value. * @returns {Promise<RequestMetadata>} An object gathering all the metadata required by the API server for validation. */ async function getRequestMetadata(request, parameters, handlers = {}) { if (request == null || parameters == null) { return null; } let handlerMetadata = {}; for (const prop in handlers) { const handler = handlers[prop]; if (typeof handler === 'function') { handlerMetadata[prop] = handler(request); } } const { enableGraphQLSupport, serverSideKey, maximumBodySize, moduleName, moduleVersion } = parameters; const clientId = handlers.ClientID ? handlerMetadata.ClientID : request.headers['x-datadome-clientid'] ?? getClientId(request.headers['cookie']); const method = handlerMetadata.Method ?? request.method; const contentType = handlerMetadata.ContentType ?? request.headers['content-type']; const url = handlerMetadata.Request ?? (request.url || request.baseUrl || '/'); let protocol = handlerMetadata.Protocol ?? (request.socket.encrypted ? 'https' : 'http'); const forwardedProto = request.headers['X-Forwarded-Proto']; if ( forwardedProto !== undefined && (forwardedProto.toLowerCase() === 'http' || forwardedProto.toLowerCase() === 'https') ) { protocol = forwardedProto; } const metadata = { Key: serverSideKey, IP: handlerMetadata.IP ?? request.socket.remoteAddress, TimeRequest: getCurrentMicroTime(), RequestModuleName: moduleName, ModuleVersion: moduleVersion, ClientID: clientId, Method: method, Port: handlerMetadata.Port ?? request.socket.remotePort, Protocol: protocol, ServerName: handlerMetadata.ServerName ?? os.hostname(), APIConnectionState: handlerMetadata.APIConnectionState ?? 'new', // Header data below; please keep sorted in alphabetical order. Accept: handlerMetadata.Accept ?? request.headers['accept'], AcceptCharset: handlerMetadata.AcceptCharset ?? request.headers['accept-charset'], AcceptEncoding: handlerMetadata.AcceptEncoding ?? request.headers['accept-encoding'], AcceptLanguage: handlerMetadata.AcceptLanguage ?? request.headers['accept-language'], AuthorizationLen: handlerMetadata.AuthorizationLen ?? getAuthorizationLength(request), CacheControl: handlerMetadata.CacheControl ?? request.headers['cache-control'], Connection: handlerMetadata.Connection ?? request.headers['connection'], ContentType: contentType, CookiesLen: handlerMetadata.CookiesLen ?? getCookiesLength(request), From: handlerMetadata.From ?? request.headers['from'], HeadersList: handlerMetadata.HeadersList ?? getHeaderNames(request), Host: handlerMetadata.Host ?? request.headers['host'], Origin: handlerMetadata.Origin ?? request.headers['origin'], PostParamLen: handlerMetadata.PostParamLen ?? request.headers['content-length'], Pragma: handlerMetadata.Pragma ?? request.headers['pragma'], Referer: handlerMetadata.Referer ?? request.headers['referer'], Request: url, SecCHDeviceMemory: handlerMetadata.SecCHDeviceMemory ?? request.headers['sec-ch-device-memory'], SecCHUA: handlerMetadata.SecCHUA ?? request.headers['sec-ch-ua'], SecCHUAArch: handlerMetadata.SecCHUAArch ?? request.headers['sec-ch-ua-arch'], SecCHUAFullVersionList: handlerMetadata.SecCHUAFullVersionList ?? request.headers['sec-ch-ua-full-version-list'], SecCHUAMobile: handlerMetadata.SecCHUAMobile ?? request.headers['sec-ch-ua-mobile'], SecCHUAModel: handlerMetadata.SecCHUAModel ?? request.headers['sec-ch-ua-model'], SecCHUAPlatform: handlerMetadata.SecCHUAPlatform ?? request.headers['sec-ch-ua-platform'], SecFetchDest: handlerMetadata.SecFetchDest ?? request.headers['sec-fetch-dest'], SecFetchMode: handlerMetadata.SecFetchMode ?? request.headers['sec-fetch-mode'], SecFetchSite: handlerMetadata.SecFetchSite ?? request.headers['sec-fetch-site'], SecFetchUser: handlerMetadata.SecFetchUser ?? request.headers['sec-fetch-user'], ServerHostname: handlerMetadata.ServerHostname ?? request.headers['host'], TrueClientIP: handlerMetadata.TrueClientIP ?? request.headers['true-client-ip'], UserAgent: handlerMetadata.UserAgent ?? request.headers['user-agent'], Via: handlerMetadata.Via ?? request.headers['via'], XForwardedForIP: handlerMetadata.XForwardedForIP ?? request.headers['x-forwarded-for'], 'X-Real-IP': handlerMetadata['X-Real-IP'] ?? request.headers['x-real-ip'], 'X-Requested-With': handlerMetadata['X-Requested-With'] ?? request.headers['x-requested-with'], }; try { const fullUrl = new URL(request.url, `${protocol}://${metadata.Host}`); if ( enableGraphQLSupport === true && isGraphQLRequest({ method, contentType, url: fullUrl, bodyExists: request.headers['content-length'] > 0, }) ) { const graphQLData = await collectGraphQL(request, fullUrl, maximumBodySize); if (isGraphQLDataAvailable(graphQLData)) { metadata['GraphQLOperationType'] = graphQLData['type']; metadata['GraphQLOperationName'] = graphQLData['name']; metadata['GraphQLOperationCount'] = graphQLData['count']; } } } catch (e) { if (e instanceof TypeError) { parameters.logger.error(`Error during the creation of the URL: ${e.message}`); } else if (e instanceof Error) { parameters.logger.error(`Error during collection of GraphQL data: ${e.message}`); } } return truncatePayloadValues(metadata); } /** * Enrich headers of the client request with the ones provided with the API response. * @param {ServerResponse} apiResponse - A response from the DataDome API. * @param {IncomingMessage} clientRequest - A client request where the headers should be applied. * @returns {IncomingMessage|null} The same request from the input, with additional headers from the API. */ function mergeDataDomeRequestHeaders(apiResponse, clientRequest) { /** @type string */ const ddRequestHeadersValue = apiResponse.headers['x-datadome-request-headers']; if (ddRequestHeadersValue == null) { return null; } ddRequestHeadersValue.split(' ').forEach((name) => { const headerName = name.toLowerCase(); const headerValue = apiResponse.headers[headerName]; if (headerValue == null) { return; } clientRequest.headers[headerName] = headerValue; }); return clientRequest; } /** * Enrich headers of the response sent to the client with the ones provided with the API response. * @param {ServerResponse} ddResponse - A response from the DataDome API. * @param {ServerResponse} clientResponse - A client response where the headers should be applied. * @returns {ServerResponse|null} The same response from the input, with additional headers from the API. */ function mergeDataDomeResponseHeaders(ddResponse, clientResponse) { /** @type string */ const ddResponseHeadersValue = getHeaderValue(ddResponse, 'x-datadome-headers'); if (ddResponseHeadersValue == null) { return null; } ddResponseHeadersValue.split(' ').forEach((name) => { const headerName = name.toLowerCase(); const headerValue = getHeaderValue(ddResponse, headerName); if (headerValue == null) { return; } if (headerName === 'set-cookie') { let existingCookies = getHeaderValue(clientResponse, headerName) || []; existingCookies = typeof existingCookies === 'string' ? [existingCookies] : existingCookies; clientResponse.setHeader(headerName, [headerValue[0], ...existingCookies]); } else { clientResponse.setHeader(headerName, headerValue); } }); return clientResponse; } // Internal functions below. /** * @param {IncomingMessage} clientRequest - A client request. * @returns {number} The length of the `Authorization` request header, 0 if there is no such header. */ function getAuthorizationLength(clientRequest) { const authorization = clientRequest.headers['authorization']; return authorization == null ? 0 : authorization.length; } /** * @param {IncomingMessage} clientRequest - A client request. * @returns {number} The length of the `Cookie` request header, 0 if there is no cookie. */ function getCookiesLength(clientRequest) { const cookie = clientRequest.headers['cookie']; return cookie == null ? 0 : cookie.length; } /** * @returns {number} The current timestamp in microseconds. */ function getCurrentMicroTime() { return Date.now() * 1000; } /** * @param {IncomingMessage} clientRequest - A client request. * @returns {string} All request header names as a comma-separated text list. */ function getHeaderNames(clientRequest) { return Object.keys(clientRequest.headers).join(','); } /** * Transform a cookie string into dictionary form. * @param {string} input - Cookies in string form, typically coming from a `Cookie` HTTP header. * @returns {Map<string, string>} */ function parseCookieString(input) { let cookies = new Map(); input.split(/; */).forEach((pair) => { let eqIndex = pair.indexOf('='); if (eqIndex > 0) { const key = pair.substring(0, eqIndex).trim(); let value = pair.substring(++eqIndex, eqIndex + pair.length).trim(); if (value[0] === '"') { value = value.slice(1, -1); } if (!cookies.has(key)) { cookies.set(key, tryDecode(value)); } } }); return cookies; } /** * Truncate values on a given metadata object according to a set of limits. * @param {RequestMetadata} metadata - A metadata object with uncontrolled values. * @returns {RequestMetadata} The same metadata object with truncated values. */ function truncatePayloadValues(metadata) { for (const prop in metadata) { const limit = limits(prop); if (limit !== 0) { metadata[prop] = truncateValue(metadata[prop], limit); } } return metadata; } /** * Truncate a value to a given length if possible. * @param {*} value - A value to truncate. * @param {number} length - The required length for the input; if a negative value is provided, the input will be truncated from the end. * @returns {string} Input truncated to the required length or an empty string if the input is invalid. * */ function truncateValue(value, length) { if (value == null) { return ''; } return length < 0 ? String(value).slice(length) : String(value).slice(0, length); } /** * Safely decode a string that might be URL-encoded. * @param {string} input * @returns {string} */ function tryDecode(input) { try { return decodeURIComponent(input); } catch (e) { return input; } } /** * Return a header value from an HTTP request or response. * @param {ServerResponse | IncomingMessage} httpEntity - An HTTP request or response. * @param {string} key - A name for the header value to return. * @returns {string | Array<string> | null} */ function getHeaderValue(httpEntity, key) { if (httpEntity.getHeader) { return httpEntity.getHeader(key); } return httpEntity.headers[key]; } /** * Adds a nonce attribute to script and style tags within our response HTML body. * @param {string} htmlBody - The HTML body content as a string. * @param {string} nonce - The nonce value to be added as an attribute. * @returns {string} The modified HTML body with nonce attributes added. */ function addNonceToResponseBody(htmlBody, nonce) { return htmlBody .replace(/<script/g, `<script nonce="${nonce}" `) .replace(/<style/g, `<style nonce="${nonce}" `); }