Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,15 @@ https://github.com/switcherapi/switcher-api
## Module initialization
The context properties stores all information regarding connectivity.

> Flags required
```
--allow-read
--allow-write
--allow-net
```

```ts
import { Switcher } from "https://deno.land/x/switcher4deno@v1.0.1/mod.ts";
import { Switcher } from "https://deno.land/x/switcher4deno@v1.0.2/mod.ts";

const url = 'https://switcherapi.com/api';
const apiKey = '[API_KEY]';
Expand Down Expand Up @@ -71,6 +78,8 @@ const switcher = Switcher.factory();
- **snapshotLocation**: Location of snapshot files. The default value is './snapshot/'.
- **silentMode**: If activated, all connectivity issues will be ignored and the client will automatically fetch the configuration into your snapshot file.
- **retryAfter** : Time given to the module to re-establish connectivity with the API - e.g. 5s (s: seconds - m: minutes - h: hours).
- **regexMaxBlackList**: Number of entries cached when REGEX Strategy fails to perform (reDOS safe) - default: 50
- **regexMaxTimeLimit**: Time limit (ms) used by REGEX workers (reDOS safe) - default - 3000ms

## Executing
There are a few different ways to call the API using the JavaScript module.
Expand All @@ -97,7 +106,7 @@ switcher.isItOn('KEY')
Loading information into the switcher can be made by using *prepare*, in case you want to include input from a different place of your code. Otherwise, it is also possible to include everything in the same call.

```ts
import { checkValue, checkNetwork } from "https://deno.land/x/switcher4deno@v1.0.1/mod.ts";
import { checkValue, checkNetwork } from "https://deno.land/x/switcher4deno@v1.0.2/mod.ts";

switcher.prepare('FEATURE01', [checkValue('USER_1')];
switcher.isItOn();
Expand Down
16 changes: 16 additions & 0 deletions snapshot/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@
}
],
"components": []
},
{
"key": "FF2FOR2024",
"description": "reDOS safe test",
"activated": true,
"strategies": [
{
"strategy": "REGEX_VALIDATION",
"activated": true,
"operation": "EXIST",
"values": [
"^(([a-z])+.)+[A-Z]([a-z])+$"
]
}
],
"components": []
}
]
},
Expand Down
26 changes: 13 additions & 13 deletions src/lib/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { processOperation } from './snapshot.ts';
import * as services from '../lib/remote.ts';

function resolveCriteria(
async function resolveCriteria(
data: any,
key: string,
input?: string[][],
Expand All @@ -15,7 +15,7 @@ function resolveCriteria(
}

const { group } = data.domain;
if (!checkGroup(group, key, input)) {
if (!(await checkGroup(group, key, input))) {
throw new Error(
`Something went wrong: {"error":"Unable to load a key ${key}"}`,
);
Expand Down Expand Up @@ -43,7 +43,7 @@ function resolveCriteria(
* @param {*} input strategy if exists
* @return true if Switcher found
*/
function checkGroup(
async function checkGroup(
groups: any[],
key: string,
input?: string[][],
Expand All @@ -54,7 +54,7 @@ function checkGroup(
const configFound = config.filter((c: { key: string }) => c.key === key);

// Switcher Configs are always supplied as the snapshot is loaded from components linked to the Switcher.
if (checkConfig(group, configFound[0], input)) {
if (await checkConfig(group, configFound[0], input)) {
return true;
}
}
Expand All @@ -68,7 +68,7 @@ function checkGroup(
* @param {*} input Strategy input if exists
* @return true if Switcher found
*/
function checkConfig(group: any, config: any, input?: string[][]) {
async function checkConfig(group: any, config: any, input?: string[][]) {
if (!config) {
return false;
}
Expand All @@ -82,13 +82,13 @@ function checkConfig(group: any, config: any, input?: string[][]) {
}

if (config.strategies) {
return checkStrategy(config, input || []);
return await checkStrategy(config, input || []);
}

return true;
}

function checkStrategy(config: any, input: string[][]) {
async function checkStrategy(config: any, input: string[][]) {
const { strategies } = config;
const entry = services.getEntry(input);

Expand All @@ -97,23 +97,23 @@ function checkStrategy(config: any, input: string[][]) {
continue;
}

checkStrategyInput(entry, strategy);
await checkStrategyInput(entry, strategy);
}

return true;
}

function checkStrategyInput(entry?: any[], strategyInput?: any) {
async function checkStrategyInput(entry?: any[], strategyInput?: any) {
if (entry && entry.length) {
const strategyEntry = entry.filter((e) => e.strategy === strategyInput.strategy);
if (
strategyEntry.length == 0 ||
!processOperation(
!(await processOperation(
strategyInput.strategy,
strategyInput.operation,
strategyEntry[0].input,
strategyInput.values,
)
))
) {
throw new CriteriaFailed(
`Strategy '${strategyInput.strategy}' does not agree`,
Expand All @@ -126,7 +126,7 @@ function checkStrategyInput(entry?: any[], strategyInput?: any) {
}
}

export default function checkCriteriaOffline(
export default async function checkCriteriaOffline(
snapshot: any,
key: string,
input?: string[][],
Expand All @@ -138,7 +138,7 @@ export default function checkCriteriaOffline(
}

const { data } = snapshot;
return resolveCriteria(data, key, input);
return await resolveCriteria(data, key, input);
}

class CriteriaFailed extends Error {
Expand Down
25 changes: 10 additions & 15 deletions src/lib/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync } from 'https://deno.land/std@0.110.0/fs/mod.ts';

import DateMoment from './utils/datemoment.ts';
import IPCIDR from './utils/ipcidr.ts';
import TimedMatch from './utils/timed-match/index.ts';
import { parseJSON, payloadReader } from './utils/payloadReader.ts';
import { CheckSwitcherError } from './exceptions/index.ts';
import { checkSnapshotVersion, resolveSnapshot } from './remote.ts';
Expand Down Expand Up @@ -111,12 +112,12 @@ export const OperationsType = Object.freeze({
HAS_ALL: 'HAS_ALL',
});

export const processOperation = (
export const processOperation = async (
strategy: string,
operation: string,
input: string,
values: string[],
) => {
): Promise<boolean | undefined> => {
switch (strategy) {
case StrategiesType.NETWORK:
return processNETWORK(operation, input, values);
Expand Down Expand Up @@ -248,26 +249,20 @@ function processDATE(operation: string, input: string, values: string[]) {
}
}

function processREGEX(
async function processREGEX(
operation: string,
input: string,
values: string[],
): boolean {
): Promise<boolean> {
switch (operation) {
case OperationsType.EXIST: {
for (const value of values) {
if (input.match(value)) {
return true;
}
}
return false;
}
case OperationsType.EXIST:
return await TimedMatch.tryMatch(values, input);
case OperationsType.NOT_EXIST:
return !processREGEX(OperationsType.EXIST, input, values);
return !(await processREGEX(OperationsType.EXIST, input, values));
case OperationsType.EQUAL:
return input.match(`\\b${values[0]}\\b`) != null;
return await TimedMatch.tryMatch([`\\b${values[0]}\\b`], input);
case OperationsType.NOT_EQUAL:
return !processREGEX(OperationsType.EQUAL, input, values);
return !(await TimedMatch.tryMatch([`\\b${values[0]}\\b`], input));
default:
return false;
}
Expand Down
107 changes: 107 additions & 0 deletions src/lib/utils/timed-match/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* This class will run a match operation using a child process.
* Workers should be killed given a specified (3000 ms default) time limit.
* Blacklist caching is available to prevent sequence of matching failures and resource usage.
*/
export default class TimedMatch {
private static worker: Worker = this.createChildProcess();
private static blacklisted: _Blacklist[] = [];
private static maxBlackListed = 50;
private static maxTimeLimit = 3000;

/**
* Run match using child process
*
* @param {*} values array of regular expression to be evaluated
* @param {*} input to be matched
* @returns match result
*/
static async tryMatch(values: string[], input: string): Promise<boolean> {
let result = false;
let timer: number, resolveListener: (value: unknown) => void;

if (this.isBlackListed(values, input)) {
return false;
}

const matchPromise = new Promise((resolve) => {
resolveListener = resolve;
this.worker.onmessage = (e) => resolveListener(e.data);
this.worker.postMessage({ values, input });
});

const matchTimer = new Promise((resolve) => {
timer = setTimeout(() => {
this.resetWorker(values, input);
resolve(false);
}, this.maxTimeLimit);
});

await Promise.race([matchPromise, matchTimer]).then((value) => {
this.worker.removeEventListener('message', resolveListener);
clearTimeout(timer);
result = Boolean(value);
});

return result;
}

/**
* Clear entries from failed matching operations
*/
static clearBlackList() {
this.blacklisted = [];
}

static setMaxBlackListed(value: number): void {
this.maxBlackListed = value;
}

static setMaxTimeLimit(value: number): void {
this.maxTimeLimit = value;
}

private static isBlackListed(values: string[], input: string): boolean {
const bls = this.blacklisted.filter((bl) =>
// input can contain same segment that could fail matching operation
(bl.input.includes(input) || input.includes(bl.input)) &&
// regex order should not affect
bl.res.filter((value) => values.includes(value)).length
);
return bls.length > 0;
}

/**
* Called when match worker fails to finish in time by;
* - Killing worker
* - Restarting new worker
* - Caching entry to the blacklist
*
* @param {*} param0 list of regex and input
*/
private static resetWorker(values: string[], input: string) {
this.worker.terminate();
this.worker = this.createChildProcess();

if (this.blacklisted.length == this.maxBlackListed) {
this.blacklisted.splice(0, 1);
}

this.blacklisted.push({
res: values,
input,
});
}

private static createChildProcess(): Worker {
const workerUrl = new URL('./worker.ts', import.meta.url).href;
return new Worker(workerUrl, {
type: 'module',
});
}
}

class _Blacklist {
res: string[] = [];
input = '';
}
21 changes: 21 additions & 0 deletions src/lib/utils/timed-match/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
function tryMatch(values: string[], input: string): boolean {
let result = false;
for (const value of values) {
if (input.match(value)) {
result = true;
break;
}
}

return result;
}

self.onmessage = (e: MessageEvent<_Param>) => {
const params: _Param = e.data;
self.postMessage(tryMatch(params.values, params.input));
};

class _Param {
values: string[] = [];
input = '';
}
Loading