Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Get Context Fortification #1234

Merged
merged 2 commits into from
Dec 23, 2024
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
180 changes: 177 additions & 3 deletions packages/snap-toolbox/src/getContext/getContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,16 +260,18 @@ describe('getContext', () => {
});
});

it('supports evaluation of all valid javascript', () => {
it('supports evaluation of all valid javascript - errors will not throw but will be undefined', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring/recommend');
scriptTag.innerHTML = `
error = window.dne.property
`;

let vars: { [key: string]: any } = {};
expect(() => {
getContext(['error'], scriptTag);
}).toThrow();
vars = getContext(['error'], scriptTag);
}).not.toThrow();
expect(vars?.error).toBeUndefined();
});

it('does not throw an error when variables exist already, but are not in evaluation list', () => {
Expand All @@ -285,4 +287,176 @@ describe('getContext', () => {
getContext(['error'], scriptTag);
}).not.toThrow();
});

it('does not attempt to evaluate variable assignments when they are within quotes', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring/recommend');
scriptTag.setAttribute('id', 'searchspring-recommend');
scriptTag.innerHTML = 'format = "<span class=money>${{amount}}</span>";';

expect(() => {
const vars = getContext(['format'], scriptTag);

expect(vars).toHaveProperty('format', '<span class=money>${{amount}}</span>');
}).not.toThrow();
});

it('logs an error when there is invalid syntax in the script context', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring');
scriptTag.innerHTML = `
valid = 'valid';
invalid = syntax error;
`;

const consoleError = jest.spyOn(console, 'error');

expect(() => {
const context = getContext(['valid', 'invalid'], scriptTag);
expect(consoleError).toHaveBeenCalledWith("getContext: error evaluating 'valid'");
expect(consoleError).toHaveBeenCalledWith("getContext: error evaluating 'invalid'");
expect(context).toStrictEqual({});
}).not.toThrow();

consoleError.mockRestore();
});

it('does not throw an error when keywords are provided in the evaluate array, but logs an error', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring');

const consoleError = jest.spyOn(console, 'error');

expect(() => {
// invalid param that should generate an error - `getContext: JavaScript keyword found: '${item}'! Please use a different variable name.`
getContext(['class', 'const', 'if', 'valid'], scriptTag);
expect(consoleError).toHaveBeenCalledWith("getContext: JavaScript keyword found: 'class'! Please use a different variable name.");
expect(consoleError).toHaveBeenCalledWith("getContext: JavaScript keyword found: 'const'! Please use a different variable name.");
expect(consoleError).toHaveBeenCalledWith("getContext: JavaScript keyword found: 'if'! Please use a different variable name.");
expect(consoleError).not.toHaveBeenCalledWith("getContext: JavaScript keyword found: 'valid'! Please use a different variable name.");

expect(consoleError).toHaveBeenCalledTimes(3);
}).not.toThrow();

consoleError.mockRestore();
});

it('does not throw when keywords are using in inner script variables but logs an error and returns an empty context', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring');
scriptTag.innerHTML = `
class = "should-not-evaluate";
const = "should-not-evaluate";
if = "should-not-evaluate";
validVar = "should-evaluate";
`;

const consoleError = jest.spyOn(console, 'error');

expect(() => {
const context = getContext(['validVar'], scriptTag);

expect(consoleError).toHaveBeenCalledWith("getContext: JavaScript keyword found: 'class'! Please use a different variable name.");
expect(consoleError).toHaveBeenCalledWith("getContext: JavaScript keyword found: 'const'! Please use a different variable name.");
expect(consoleError).toHaveBeenCalledWith("getContext: JavaScript keyword found: 'if'! Please use a different variable name.");
expect(consoleError).not.toHaveBeenCalledWith("getContext: JavaScript keyword found: 'validVar'! Please use a different variable name.");

expect(consoleError).toHaveBeenCalledWith("getContext: error evaluating 'validVar'");

// logs above errors plus the actual error when attempting to evaluate "validVar"
expect(consoleError).toHaveBeenCalledTimes(5);

expect(context).toStrictEqual({});
}).not.toThrow();

consoleError.mockRestore();
});

it('allows javascript keywords in object properties and string values', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring');
scriptTag.innerHTML = `
config = {
class: "class",
const: "const",
if: true
};
`;

const vars = getContext(['config'], scriptTag);
expect(vars).toHaveProperty('config');
expect(vars.config).toEqual({
class: 'class',
const: 'const',
if: true,
});
});
});

describe('variable name parsing', () => {
it('correctly identifies variable names when quotes are present', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring');
scriptTag.innerHTML = `
realVar = "something = 123";
anotherVar = 'test = value';
actualValue = 456;
`;

const vars = getContext(['realVar', 'anotherVar', 'actualValue', 'something', 'test'], scriptTag);
expect(Object.keys(vars)).toHaveLength(3);
expect(vars).toHaveProperty('realVar', 'something = 123');
expect(vars).toHaveProperty('anotherVar', 'test = value');
expect(vars).toHaveProperty('actualValue', 456);
expect(vars).not.toHaveProperty('something');
expect(vars).not.toHaveProperty('test');
});

it('handles template literals correctly', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring');
scriptTag.innerHTML = `
template = \`
<div>
\${value}
\${name}
</div>
\`;
actual = "real value";
`;

const vars = getContext(['template', 'actual', 'value', 'name'], scriptTag);
expect(Object.keys(vars)).toHaveLength(2);
expect(vars).toHaveProperty('template');
expect(vars).toHaveProperty('actual', 'real value');
});

it('handles HTML attributes that look like assignments', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring');
scriptTag.innerHTML = `
html = '<div class="test" data-value="something = 123"></div>';
value = 'real value';
`;

const vars = getContext(['html', 'value'], scriptTag);
expect(Object.keys(vars)).toHaveLength(2);
expect(vars).toHaveProperty('html');
expect(vars).toHaveProperty('value', 'real value');
});

it('handles nested quotes correctly', () => {
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'searchspring');
scriptTag.innerHTML = `
config = "{ \\"nested = value\\": true }";
actual = 123;
`;

const vars = getContext(['config', 'actual', 'nested'], scriptTag);
expect(Object.keys(vars)).toHaveLength(2);
expect(vars).toHaveProperty('config');
expect(vars).toHaveProperty('actual', 123);
expect(vars).not.toHaveProperty('nested');
});
});
83 changes: 74 additions & 9 deletions packages/snap-toolbox/src/getContext/getContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,52 @@ type ContextVariables = {
[variable: string]: any;
};

const JAVASCRIPT_KEYWORDS = new Set([
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'export',
'extends',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'return',
'super',
'switch',
'this',
'throw',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
'let',
'static',
'enum',
'await',
'implements',
'package',
'protected',
'interface',
'private',
'public',
]);

export function getContext(evaluate: string[] = [], script?: HTMLScriptElement | string): ContextVariables {
if (!script || typeof script === 'string') {
const scripts = Array.from(document.querySelectorAll((script as string) || 'script[id^=searchspring], script[src*="snapui.searchspring.io"]'));
Expand Down Expand Up @@ -49,24 +95,43 @@ export function getContext(evaluate: string[] = [], script?: HTMLScriptElement |
const scriptInnerHTML = scriptElem.innerHTML;

// attempt to grab inner HTML variables
const scriptInnerVars = scriptInnerHTML.match(/([a-zA-Z_$][a-zA-Z_$0-9]*)\s?=/g)?.map((match) => match.replace(/[\s=]/g, ''));
const scriptInnerVars = scriptInnerHTML
// first remove all string literals (including template literals) to avoid false matches
.replace(/`(?:\\[\s\S]|[^`\\])*`|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"/g, '')
// then find variable assignments
.match(/([a-zA-Z_$][a-zA-Z_$0-9]*)\s*=/g)
?.map((match) => match.replace(/[\s=]/g, ''));

const combinedVars = evaluate.concat(scriptInnerVars || []);

// de-dupe vars
const evaluateVars = combinedVars.filter((item, index) => {
return combinedVars.indexOf(item) === index;
const isKeyword = JAVASCRIPT_KEYWORDS.has(item);
// console error if keyword
if (isKeyword) {
console.error(`getContext: JavaScript keyword found: '${item}'! Please use a different variable name.`);
}
return combinedVars.indexOf(item) === index && !isKeyword;
});

// evaluate text and put into variables
evaluate?.forEach((name) => {
const fn = new Function(`
var ${evaluateVars.join(', ')};
${scriptInnerHTML}
return ${name};
`);

scriptVariables[name] = fn();
try {
const fn = new Function(`
var ${evaluateVars.join(', ')};
${scriptInnerHTML}
return ${name};
`);
scriptVariables[name] = fn();
} catch (err) {
// if evaluation fails, set to undefined
const isKeyword = JAVASCRIPT_KEYWORDS.has(name);
if (!isKeyword) {
console.error(`getContext: error evaluating '${name}'`);
console.error(err);
}
scriptVariables[name] = undefined;
}
});

const variables = {
Expand Down
Loading