@datadome/node-core
Version:
Core package for server-side modules using Node.js
510 lines (447 loc) • 18.6 kB
JavaScript
/**
* 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}" `);
}