Merge pull request #1549 from Bilb/onion-routing-for-new-open-groups

remove fallbacks to node-fetch
This commit is contained in:
Audric Ackermann 2021-03-23 19:28:44 -07:00 committed by GitHub
commit a010630775
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 191 additions and 185 deletions

View File

@ -23,7 +23,6 @@ if (environment === 'production') {
process.env.HOSTNAME = '';
process.env.ALLOW_CONFIG_MUTATIONS = '';
process.env.SUPPRESS_NO_CONFIG_WARNING = '';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '';
// We could be running againt production but still be in dev mode, we need to handle that
if (!isDevelopment) {

View File

@ -2,7 +2,7 @@
/* global window */
const FormData = require('form-data');
const fetch = require('node-fetch');
const insecureNodeFetch = require('node-fetch');
const BASE_URL = 'https://debuglogs.org';
const VERSION = window.getVersion();
@ -10,7 +10,8 @@ const USER_AGENT = `Session ${VERSION}`;
// upload :: String -> Promise URL
exports.upload = async content => {
const signedForm = await fetch(BASE_URL, {
window.log.warn('insecureNodeFetch => upload debugLogs');
const signedForm = await insecureNodeFetch(BASE_URL, {
headers: {
'user-agent': USER_AGENT,
},
@ -38,7 +39,7 @@ exports.upload = async content => {
filename: `session-desktop-debug-log-${VERSION}.txt`,
});
const result = await fetch(url, {
const result = await insecureNodeFetch(url, {
method: 'POST',
body: form,
});

View File

@ -1,7 +1,7 @@
/* global log, textsecure, libloki, Signal, Whisper,
clearTimeout, getMessageController, libsignal, StringView, window, _,
dcodeIO, Buffer, process */
const nodeFetch = require('node-fetch');
const insecureNodeFetch = require('node-fetch');
const { URL, URLSearchParams } = require('url');
const FormData = require('form-data');
const https = require('https');
@ -253,7 +253,7 @@ const serverRequest = async (endpoint, options = {}) => {
let response;
let result;
let txtResponse;
let mode = 'nodeFetch';
let mode = 'insecureNodeFetch';
try {
const host = url.host.toLowerCase();
// log.info('host', host, FILESERVER_HOSTS);
@ -268,7 +268,12 @@ const serverRequest = async (endpoint, options = {}) => {
fetchOptions,
options
));
} else if (window.lokiFeatureFlags.useFileOnionRequests && srvPubKey) {
} else if (window.lokiFeatureFlags.useFileOnionRequests) {
if (!srvPubKey) {
throw new Error(
'useFileOnionRequests=true but we do not have a server pubkey set.'
);
}
mode = 'sendViaOnionOG';
({ response, txtResponse, result } = await sendViaOnion(
srvPubKey,
@ -277,13 +282,9 @@ const serverRequest = async (endpoint, options = {}) => {
options
));
} else {
// disable check for .loki
process.env.NODE_TLS_REJECT_UNAUTHORIZED = host.match(/\.loki$/i)
? '0'
: '1';
result = await nodeFetch(url, fetchOptions);
// always make sure this check is enabled
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
// we end up here only if window.lokiFeatureFlags.useFileOnionRequests is false
log.info(`insecureNodeFetch => plaintext for ${url}`);
result = await insecureNodeFetch(url, fetchOptions);
txtResponse = await result.text();
// cloudflare timeouts (504s) will be html...
@ -1395,23 +1396,13 @@ class LokiPublicChannelAPI {
// do we already have this image? no, then
// download a copy and save it
const imageData = await nodeFetch(avatarAbsUrl);
// eslint-disable-next-line no-inner-declarations
function toArrayBuffer(buf) {
const ab = new ArrayBuffer(buf.length);
const view = new Uint8Array(ab);
// eslint-disable-next-line no-plusplus
for (let i = 0; i < buf.length; i++) {
view[i] = buf[i];
}
return ab;
}
// eslint-enable-next-line no-inner-declarations
const imageData = await this.serverAPI.downloadAttachment(
avatarAbsUrl
);
const buffer = await imageData.buffer();
const newAttributes = await window.Signal.Types.Conversation.maybeUpdateAvatar(
this.conversation.attributes,
toArrayBuffer(buffer),
imageData,
{
writeNewAttachmentData,
deleteAttachmentData,

View File

@ -41,9 +41,14 @@ async function allowOnlyOneAtATime(name, process, timeout) {
try {
innerRetVal = await process();
} catch (e) {
log.error(
`loki_primitives:::allowOnlyOneAtATime - error ${e.code} ${e.message}`
);
if (typeof e === 'string') {
log.error(`loki_primitives:::allowOnlyOneAtATime - error ${e}`);
} else {
log.error(
`loki_primitives:::allowOnlyOneAtATime - error ${e.code} ${e.message}`
);
}
// clear timeout timer
if (timeout) {
if (timeoutTimer !== null) {

View File

@ -2,78 +2,93 @@
const EventEmitter = require('events');
const LokiAppDotNetAPI = require('./loki_app_dot_net_api');
const nodeFetch = require('node-fetch');
const insecureNodeFetch = require('node-fetch');
/**
* Tries to establish a connection with the specified open group url.
*
* This will try to do an onion routing call if the `useFileOnionRequests` feature flag is set,
* or call directly insecureNodeFetch if it's not.
*
* Returns
* * true if useFileOnionRequests is false and no exception where thrown by insecureNodeFetch
* * true if useFileOnionRequests is true and we established a connection to the server with onion routing
* * false otherwise
*
*/
const validOpenGroupServer = async serverUrl => {
// test to make sure it's online (and maybe has a valid SSL cert)
try {
const url = new URL(serverUrl);
if (window.lokiFeatureFlags.useFileOnionRequests) {
// check for LSRPC
if (!window.lokiFeatureFlags.useFileOnionRequests) {
// we are not running with onion request
// this is an insecure insecureNodeFetch. It will expose the user ip to the serverUrl (not onion routed)
log.info(`insecureNodeFetch => plaintext for ${url.toString()}`);
// this is safe (as long as node's in your trust model)
// because
const result = await window.tokenlessFileServerAdnAPI.serverRequest(
`loki/v1/getOpenGroupKey/${url.hostname}`
);
if (result.response.meta.code === 200) {
// supports it
const obj = JSON.parse(result.response.data);
const pubKey = dcodeIO.ByteBuffer.wrap(
obj.data,
'base64'
).toArrayBuffer();
// verify it works...
// get around the FILESERVER_HOSTS filter by not using serverRequest
const res = await LokiAppDotNetAPI.sendViaOnion(
pubKey,
url,
{ method: 'GET' },
{ noJson: true }
);
if (res.result && res.result.status === 200) {
log.info(
`loki_public_chat::validOpenGroupServer - onion routing enabled on ${url.toString()}`
);
// save pubkey for use...
window.lokiPublicChatAPI.openGroupPubKeys[serverUrl] = pubKey;
return true;
}
// otherwise fall back
} else if (result.response.meta.code !== 404) {
// unknown error code
log.warn(
'loki_public_chat::validOpenGroupServer - unknown error code',
result.response.meta
);
}
// we probably have to check the response here
await insecureNodeFetch(serverUrl);
return true;
}
// doesn't support it, fallback
log.info(
`loki_public_chat::validOpenGroupServer - directly contacting ${url.toString()}`
// This MUST be an onion routing call, no nodeFetch calls below here.
/**
* this is safe (as long as node's in your trust model)
*
* First, we need to fetch the open group public key of this open group.
* The fileserver have all the open groups public keys.
* We need the open group public key because for onion routing we will need to encode
* our request with it.
* We can just ask the file-server to get the one for the open group we are trying to add.
*/
const result = await window.tokenlessFileServerAdnAPI.serverRequest(
`loki/v1/getOpenGroupKey/${url.hostname}`
);
// allow .loki (may only need an agent but not sure
// until we have a .loki to test with)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = url.host.match(/\.loki$/i)
? '0'
: '1';
await nodeFetch(serverUrl);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
// const txt = await res.text();
if (result.response.meta.code === 200) {
// we got the public key of the server we are trying to add.
// decode it.
const obj = JSON.parse(result.response.data);
const pubKey = dcodeIO.ByteBuffer.wrap(
obj.data,
'base64'
).toArrayBuffer();
// verify we can make an onion routed call to that open group with the decoded public key
// get around the FILESERVER_HOSTS filter by not using serverRequest
const res = await LokiAppDotNetAPI.sendViaOnion(
pubKey,
url,
{ method: 'GET' },
{ noJson: true }
);
if (res.result && res.result.status === 200) {
log.info(
`loki_public_chat::validOpenGroupServer - onion routing enabled on ${url.toString()}`
);
// save pubkey for use...
window.lokiPublicChatAPI.openGroupPubKeys[serverUrl] = pubKey;
return true;
}
// return here, just so we are sure adding some code below won't do a nodeFetch fallback
return false;
} else if (result.response.meta.code !== 404) {
// unknown error code
log.warn(
'loki_public_chat::validOpenGroupServer - unknown error code',
result.response.meta
);
}
return false;
} catch (e) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
log.warn(
`loki_public_chat::validOpenGroupServer - failing to create ${serverUrl}`,
e.code,
e.message
);
// bail out if not valid enough
return false;
}
return true;
return false;
};
class LokiPublicChatFactoryAPI extends EventEmitter {

View File

@ -127,8 +127,7 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
if (error) {
ToastUtils.pushToastError('addContact', error);
} else {
// tslint:disable-next-line: no-floating-promises
ConversationController.getInstance()
void ConversationController.getInstance()
.getOrCreateAndWait(sessionID, 'private')
.then(() => {
this.props.openConversationExternal(sessionID);

View File

@ -2,7 +2,7 @@ import React from 'react';
import { arrayBufferFromFile } from '../../../types/Attachment';
import { AttachmentUtil, LinkPreviewUtil } from '../../../util';
import { StagedLinkPreviewData } from './SessionCompositionBox';
import fetch from 'node-fetch';
import { default as insecureNodeFetch } from 'node-fetch';
import { fetchLinkPreviewImage } from '../../../util/linkPreviewFetch';
import { AbortSignal } from 'abort-controller';
import { StagedLinkPreview } from '../../conversation/StagedLinkPreview';
@ -37,8 +37,10 @@ export const getPreview = async (
throw new Error('Link not safe for preview');
}
window.log.info('insecureNodeFetch => plaintext for getPreview()');
const linkPreviewMetadata = await LinkPreviewUtil.fetchLinkPreviewMetadata(
fetch,
insecureNodeFetch,
url,
abortSignal
);
@ -51,8 +53,10 @@ export const getPreview = async (
if (imageHref && window.Signal.LinkPreviews.isLinkSafeToPreview(imageHref)) {
let objectUrl: void | string;
try {
window.log.info('insecureNodeFetch => plaintext for getPreview()');
const fullSizeImage = await fetchLinkPreviewImage(
fetch,
insecureNodeFetch,
imageHref,
abortSignal
);

View File

@ -437,9 +437,9 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const newAdmins = _.sortBy(groupAdmins);
if (_.isEqual(existingAdmins, newAdmins)) {
window.log.info(
'Skipping updates of groupAdmins/moderators. No change detected.'
);
// window.log.info(
// 'Skipping updates of groupAdmins/moderators. No change detected.'
// );
return;
}
this.set({ groupAdmins });

View File

@ -2,8 +2,9 @@ import { allowOnlyOneAtATime } from '../../../js/modules/loki_primitives';
import { getGuardNodes } from '../../../ts/data/data';
import * as SnodePool from '../snode_api/snodePool';
import _ from 'lodash';
import fetch from 'node-fetch';
import { default as insecureNodeFetch } from 'node-fetch';
import { UserUtils } from '../utils';
import { snodeHttpsAgent } from '../snode_api/onions';
type Snode = SnodePool.Snode;
@ -155,23 +156,22 @@ export class OnionPaths {
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
timeout: 10000, // 10s, we want a smaller timeout for testing
agent: snodeHttpsAgent,
};
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
let response;
try {
// Log this line for testing
// curl -k -X POST -H 'Content-Type: application/json' -d '"+fetchOptions.body.replace(/"/g, "\\'")+"'", url
response = await fetch(url, fetchOptions);
window.log.info('insecureNodeFetch => plaintext for testGuardNode');
response = await insecureNodeFetch(url, fetchOptions);
} catch (e) {
if (e.type === 'request-timeout') {
log.warn('test timeout for node,', snode);
}
return false;
} finally {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
}
if (!response.ok) {

View File

@ -1,48 +1,15 @@
import fetch from 'node-fetch';
import https from 'https';
import { default as insecureNodeFetch } from 'node-fetch';
import { Snode } from './snodePool';
import { lokiOnionFetch, SnodeResponse } from './onions';
const snodeHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
async function lokiPlainFetch(
url: string,
fetchOptions: any
): Promise<boolean | SnodeResponse> {
const { log } = window;
if (url.match(/https:\/\//)) {
// import that this does not get set in lokiFetch fetchOptions
fetchOptions.agent = snodeHttpsAgent;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
} else {
log.debug('lokirpc:::lokiFetch - http communication', url);
}
const response = await fetch(url, fetchOptions);
// restore TLS checking
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
if (!response.ok) {
throw new window.textsecure.HTTPError('Loki_rpc error', response);
}
const result = await response.text();
return {
body: result,
status: response.status,
};
}
import { lokiOnionFetch, snodeHttpsAgent, SnodeResponse } from './onions';
interface FetchOptions {
method: string;
}
// A small wrapper around node-fetch which deserializes response
// returns nodeFetch response or false
// returns insecureNodeFetch response or false
async function lokiFetch(
url: string,
options: FetchOptions,
@ -64,7 +31,23 @@ async function lokiFetch(
return await lokiOnionFetch(fetchOptions.body, targetNode);
}
return await lokiPlainFetch(url, fetchOptions);
if (url.match(/https:\/\//)) {
// import that this does not get set in lokiFetch fetchOptions
fetchOptions.agent = snodeHttpsAgent;
}
window.log.warn(`insecureNodeFetch => lokiFetch of ${url}`);
const response = await insecureNodeFetch(url, fetchOptions);
if (!response.ok) {
throw new window.textsecure.HTTPError('Loki_rpc error', response);
}
const result = await response.text();
return {
body: result,
status: response.status,
};
} catch (e) {
if (e.code === 'ENOTFOUND') {
throw new window.textsecure.NotFoundError('Failed to resolve address', e);

View File

@ -1,4 +1,4 @@
import fetch from 'node-fetch';
import { default as insecureNodeFetch } from 'node-fetch';
import https from 'https';
import { Snode } from './snodePool';
@ -353,7 +353,7 @@ const processOnionResponse = async (
}
};
const snodeHttpsAgent = new https.Agent({
export const snodeHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
@ -457,7 +457,10 @@ const sendOnionRequest = async (
const target = useV2 ? '/onion_req/v2' : '/onion_req';
const guardUrl = `https://${nodePath[0].ip}:${nodePath[0].port}${target}`;
const response = await fetch(guardUrl, guardFetchOptions);
// no logs for that one as we do need to call insecureNodeFetch to our guardNode
// window.log.info('insecureNodeFetch => plaintext for sendOnionRequest');
const response = await insecureNodeFetch(guardUrl, guardFetchOptions);
return processOnionResponse(
reqIdx,

View File

@ -1,10 +1,12 @@
// we don't throw or catch here
import https from 'https';
import fetch from 'node-fetch';
import { default as insecureNodeFetch } from 'node-fetch';
import { snodeRpc } from './lokiRpc';
import { sendOnionRequestLsrpcDest, SnodeResponse } from './onions';
import {
sendOnionRequestLsrpcDest,
snodeHttpsAgent,
SnodeResponse,
} from './onions';
import { sleepFor } from '../../../js/modules/loki_primitives';
@ -17,10 +19,10 @@ import {
updateSnodesFor,
} from './snodePool';
const snodeHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
/**
* Currently unused. If we need it again, be sure to update it to onion routing rather
* than using a plain nodeFetch
*/
export async function getVersion(
node: Snode,
retries: number = 0
@ -30,11 +32,13 @@ export async function getVersion(
const { log } = window;
try {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const result = await fetch(`https://${node.ip}:${node.port}/get_stats/v1`, {
agent: snodeHttpsAgent,
});
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
window.log.warn('insecureNodeFetch => plaintext for getVersion');
const result = await insecureNodeFetch(
`https://${node.ip}:${node.port}/get_stats/v1`,
{
agent: snodeHttpsAgent,
}
);
const data = await result.json();
if (data.version) {
return data.version;
@ -105,8 +109,9 @@ export async function getSnodesFromSeedUrl(urlObj: URL): Promise<Array<any>> {
timeout: 10000,
body: JSON.stringify(body),
};
window.log.info('insecureNodeFetch => plaintext for getSnodesFromSeedUrl');
const response = await fetch(url, fetchOptions);
const response = await insecureNodeFetch(url, fetchOptions);
if (response.status !== 200) {
log.error(

View File

@ -108,8 +108,7 @@ export function markNodeUnreachable(snode: Snode): void {
for (const [pubkey, nodes] of nodesForPubkey) {
const edkeys = _.filter(nodes, edkey => edkey !== snode.pubkey_ed25519);
// tslint:disable-next-line no-floating-promises
internalUpdateSnodesFor(pubkey, edkeys);
void internalUpdateSnodesFor(pubkey, edkeys);
}
log.warn(
@ -137,13 +136,18 @@ function compareSnodes(lhs: any, rhs: any): boolean {
return lhs.pubkey_ed25519 === rhs.pubkey_ed25519;
}
// WARNING: this leaks our IP to all snodes but with no other identifying information
// except "that a client started up" or "ran out of random pool snodes"
// and the order of the list is randomized, so a snode can't tell if it just started or not
/**
* Request the version of the snode.
* THIS IS AN INSECURE NODE FETCH and leaks our IP to all snodes but with no other identifying information
* except "that a client started up" or "ran out of random pool snodes"
* and the order of the list is randomized, so a snode can't tell if it just started or not
*/
async function requestVersion(node: any): Promise<void> {
const { log } = window;
const result = await getVersion(node);
// WARNING: getVersion is doing an insecure node fetch.
// be sure to update getVersion to onion routing if we need this call again.
const result = false; // await getVersion(node);
if (result === false) {
return;
@ -178,9 +182,14 @@ export function getNodesMinVersion(minVersion: string): Array<Snode> {
);
}
// now get version for all snodes
// also acts an early online test/purge of bad nodes
export async function getAllVerionsForRandomSnodePool(): Promise<void> {
/**
* Currently unused as it makes call over insecure node fetch and we don't need
* to filter out nodes by versions anymore.
*
* now get version for all snodes
* also acts an early online test/purge of bad nodes
*/
export async function getAllVersionsForRandomSnodePool(): Promise<void> {
const { log } = window;
// let count = 0;
@ -192,7 +201,7 @@ export async function getAllVerionsForRandomSnodePool(): Promise<void> {
await requestVersion(node);
} catch (e) {
log.error(
'LokiSnodeAPI::_getAllVerionsForRandomSnodePool - error',
'LokiSnodeAPI::_getAllVersionsForRandomSnodePool - error',
e.code,
e.message
);
@ -211,7 +220,7 @@ export async function getAllVerionsForRandomSnodePool(): Promise<void> {
return curVal;
}, []);
log.debug(
`LokiSnodeAPI::_getAllVerionsForRandomSnodePool - ${versions.length} versions retrieved from network!:`,
`LokiSnodeAPI::_getAllVersionsForRandomSnodePool - ${versions.length} versions retrieved from network!:`,
versions.join(',')
);
}
@ -248,8 +257,7 @@ async function getSnodeListFromLokidSeednode(
'seed nodes total',
seedNodes.length
);
// tslint:disable-next-line:no-floating-promises
getSnodeListFromLokidSeednode(seedNodes, retries + 1);
void getSnodeListFromLokidSeednode(seedNodes, retries + 1);
}, retries * retries * 5000);
} else {
log.error('loki_snode_api::getSnodeListFromLokidSeednode - failing');
@ -262,7 +270,7 @@ async function getSnodeListFromLokidSeednode(
async function refreshRandomPoolDetail(seedNodes: Array<any>): Promise<void> {
const { log } = window;
// are we running any _getAllVerionsForRandomSnodePool
// are we running any _getAllVersionsForRandomSnodePool
if (stopGetAllVersionPromiseControl !== false) {
// we are, stop them
stopGetAllVersionPromiseControl();
@ -286,8 +294,9 @@ async function refreshRandomPoolDetail(seedNodes: Array<any>): Promise<void> {
randomSnodePool.length,
'snodes'
);
// tslint:disable-next-line:no-floating-promises
getAllVerionsForRandomSnodePool();
// Warning: the call below will call getVersions to all existing nodes.
// And not with onion routing
// void getAllVersionsForRandomSnodePool();
} catch (e) {
log.warn('LokiSnodeAPI::refreshRandomPool - error', e.code, e.message);
/*
@ -358,8 +367,7 @@ export async function getSnodesFor(pubkey: string): Promise<Array<Snode>> {
const freshNodes = _.shuffle(await requestSnodesForPubkey(pubkey));
const edkeys = freshNodes.map((n: Snode) => n.pubkey_ed25519);
// tslint:disable-next-line no-floating-promises
internalUpdateSnodesFor(pubkey, edkeys);
void internalUpdateSnodesFor(pubkey, edkeys);
// TODO: We could probably check that the retuned sndoes are not "unreachable"
return freshNodes;

View File

@ -29,7 +29,6 @@ export function processMessage(message: string, options: any = {}) {
const dataPlaintext = new Uint8Array(StringUtils.encode(message, 'base64'));
const messageBuf = SignalService.WebSocketMessage.decode(dataPlaintext);
if (messageBuf.type === SignalService.WebSocketMessage.Type.REQUEST) {
// tslint:disable-next-line no-floating-promises
Receiver.handleRequest(messageBuf.request?.body, options);
}
} catch (error) {

View File

@ -1,5 +1,5 @@
import { processMessage, SwarmPolling } from './swarmPolling';
import fetch from 'node-fetch';
import { default as insecureNodeFetch } from 'node-fetch';
import { PubKey } from '../types';
export class SwarmPollingStub extends SwarmPolling {
@ -12,7 +12,8 @@ export class SwarmPollingStub extends SwarmPolling {
method: 'GET',
};
const res = await fetch(
// insecureNodeFetch but this is a stub
const res = await insecureNodeFetch(
`${this.baseUrl}/messages?pubkey=${pubkeyStr}`,
get
);

View File

@ -1,6 +1,6 @@
import { StringUtils } from '../../../../session/utils';
import fetch from 'node-fetch';
import { default as insecureNodeFetch } from 'node-fetch';
class StubMessageAPI {
public ourKey: string;
@ -23,7 +23,9 @@ class StubMessageAPI {
};
const data64 = StringUtils.decode(data, 'base64');
await fetch(
// insecureNodeFetch but this is a stub
await insecureNodeFetch(
`${
this.baseUrl
}/messages?pubkey=${pubKey}&timestamp=${messageTimeStamp}&data=${encodeURIComponent(

View File

@ -9,7 +9,6 @@ import {
IMAGE_WEBP,
MIMEType,
} from '../types/MIME';
import { PromiseUtils } from '../session/utils';
const MAX_REQUEST_COUNT_WITH_REDIRECTS = 20;
// tslint:disable: prefer-for-of

View File

@ -4239,14 +4239,6 @@
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/node-fetch/lib/headers.js",
"line": "\t\t\t\tself.append(prop, item.toString());",
"lineNumber": 40,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-$(",
"path": "node_modules/node-forge/dist/forge.all.min.js",