diff --git a/README.md b/README.md index bf85898..768082f 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ Run CyberChef in a server and provide an API for clients to send [Cyberchef](htt CyberChef has a useful Node.js API, but sometimes we want to be able to programmatically run CyberChef recipes in languages other than JavaScript. By running this server, you can use CyberChef operations in any language, as long as you can communicate via HTTP. -## Example use -Assuming you've downloaded the repository and are running it locally: +## Examples + +### Decode some morse code ```bash curl -X POST -H "Content-Type:application/json" -d '{"input":"... ---:.-.. --- -. --. --..--:.- -. -..:- .... .- -. -.- ...:..-. --- .-.:.- .-.. .-..:- .... .:..-. .. ... ....", "recipe":{"op":"from morse code", "args": {"wordDelimiter": "Colon"}}}' localhost:3000/bake ``` @@ -25,8 +26,11 @@ response: ## Features -- **Compatible with recipes saved from CyberChef**. -After using [CyberChef](https://gchq.github.io/CyberChef/) to experiment and find a suitable recipe, the exported recipe JSON can be used to post to the `/bake` endpoint. Just copy/paste it in as your `recipe` property as part of the POST body. +- Compatible with recipes saved from CyberChef. + - After using [CyberChef](https://gchq.github.io/CyberChef/) to experiment and find a suitable recipe, the exported recipe JSON can be used to post to the `/bake` endpoint. Just copy/paste it in as your `recipe` property as part of the POST body. +- Send files as input. + - See [Bake with multipart form data](#bake-files-with-multipart/form-data) + ## Installing @@ -166,6 +170,48 @@ Response: } ``` +### Bake files with multipart/form data + +CyberChef-server will handle `multipart/form` data so you can send files as input data. + +The parts are: + +|Part|type|Description| +|---|---|--| +|input|file or field|The input to bake. This can be a path to a file, or a field.| +|recipe|file|The JSON file containing the recipe to bake the input with.| +|outputType|field|**Optional.** The [Data Type](https://github.com/gchq/CyberChef/wiki/Adding-a-new-operation#data-types) that you would like the result of the bake to be returned as.| + + +#### Example +recipe.json +```json +[ + "from hexdump", + "gunzip" +] +``` +hexdump.txt +``` +00000000 1f 8b 08 00 12 bc f3 57 00 ff 0d c7 c1 09 00 20 |.....¼óW.ÿ.ÇÁ.. | +00000010 08 05 d0 55 fe 04 2d d3 04 1f ca 8c 44 21 5b ff |..ÐUþ.-Ó..Ê.D![ÿ| +00000020 60 c7 d7 03 16 be 40 1f 78 4a 3f 09 89 0b 9a 7d |`Ç×..¾@.xJ?....}| +00000030 4e c8 4e 6d 05 1e 01 8b 4c 24 00 00 00 |NÈNm....L$...| +``` +Send the hexdump. Specify the output type as string: +``` +curl -F "input=@hexdump.txt" -F "recipe=@recipe.json" -F "outputType=string" localhost:3000/bake +``` +Response: +``` +{ + "value": "So long and thanks for all the fish.", + "type": "string" +} +``` + + + ### `/magic` [Find more information about what the Magic operation does here](https://github.com/gchq/CyberChef/wiki/Automatic-detection-of-encoded-data-using-CyberChef-Magic) diff --git a/app.js b/app.js index 5d155d5..3209561 100644 --- a/app.js +++ b/app.js @@ -16,16 +16,17 @@ const app = express(); app.disable("x-powered-by"); -if (process.env.NODE_ENV === "production") { +if (process.env.DEBUG) { app.use(pino({ - level: "warn" + level: "debug", + prettyPrint: true })); - app.use(helmet()); } else { app.use(pino({ - level: "debug", - prettyPrint: true + level: "warn", + prettyPrint: true, })); + app.use(helmet()); } app.use(express.json()); diff --git a/package-lock.json b/package-lock.json index 10198c5..bb2a312 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3038,10 +3038,9 @@ } }, "formidable": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", - "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==", - "dev": true + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", + "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==" }, "forwarded": { "version": "0.1.2", diff --git a/package.json b/package.json index b782940..fc79e0c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "esm": "^3.2.25", "express": "~4.16.1", "express-pino-logger": "^4.0.0", + "formidable": "^1.2.2", "helmet": "^3.21.1", "swagger-ui-express": "^4.1.2", "yaml": "^1.7.2" diff --git a/routes/bake.js b/routes/bake.js index 7097f7d..a62254c 100644 --- a/routes/bake.js +++ b/routes/bake.js @@ -1,11 +1,105 @@ +import fs from "fs"; +import { promisify } from "util"; + import { Router } from "express"; -const router = Router(); +import formidable from "formidable"; + import { bake, Dish } from "cyberchef/src/node/index.mjs"; +const router = Router(); +const readFile = promisify(fs.readFile); + /** * bakePost */ router.post("/", async function bakePost(req, res, next) { + if (req.is("multipart/form-data")) { + bakeMultipartForm(req, res, next); + } else { + bakeBody(req, res, next); + } +}); + +/** + * bakeMultipartForm + * + * Bake using data from multipart form data. + * recipe must be a JSON file + * input can be a file or a string in a field. + * outputType (optional) must be a string in a field. + * + * Any errors are passed onto `next`. + */ +async function bakeMultipartForm(req, res, next) { + const form = formidable(); + form.parse(req, async (err, fields, files) => { + try { + + if (err) { + throw err; + } + + if (!("recipe" in files)) { + throw new TypeError("Could not find required 'recipe' attachment in multipart form data"); + } + + let recipe; + try { + const fileContents = await readFile(files.recipe.path); + recipe = JSON.parse(fileContents); + } catch (e) { + throw new TypeError(`Could not parse recipe file: ${e}`); + } + + let dish; + + if ("input" in files) { + const input = await readFile(files.input.path); + dish = await bake(input, recipe); + + } else if ("input" in fields) { + dish = await bake(fields.input, recipe); + + } else { + throw new TypeError("Could not find 'input' field in multipart form data."); + } + + if (dish) { + + if ("outputType" in fields) { + // dish.get takes a typeEnum + let typeEnum = parseInt(fields.outputType, 10); + if (isNaN(typeEnum)) { + typeEnum = Dish.typeEnum(fields.outputType); + } + dish.get(typeEnum); + + // Browser should handle files as a download + if (typeEnum === Dish.FILE) { + res.set("Content-Disposition", "attachment"); + } + } + + res.send({ + value: dish.value, + type: Dish.enumLookup(dish.type), + }); + } + + } catch (e) { + next(e); + } + + }); +} + +/** + * bakeBody + * + * Bake using data from POST body + */ +async function bakeBody(req, res, next) { + try { if (!req.body.input) { throw new TypeError("'input' property is required in request body"); @@ -31,6 +125,6 @@ router.post("/", async function bakePost(req, res, next) { } catch (e) { next(e); } -}); +} export default router; diff --git a/sample b/sample new file mode 100644 index 0000000..7ebcf7b --- /dev/null +++ b/sample @@ -0,0 +1 @@ +{"value":{"data":{"type":"Buffer","data":[84,104,101,32,99]},"name":"unknown","lastModified":1593791384717,"type":"application/unknown"},"type":"File"} \ No newline at end of file diff --git a/test/bake.js b/test/bake.js index 7a25a83..58838b1 100644 --- a/test/bake.js +++ b/test/bake.js @@ -1,6 +1,11 @@ +import assert from "assert"; + import request from "supertest"; + import app from "../app"; +const testDataPath = "test/test_data"; + describe("GET /bake", function() { it("doesnt exist", function(done) { @@ -208,3 +213,162 @@ describe("POST /bake", function() { }); }); + +describe("POST /bake files", function () { + it("should complain if you do not send a recipe with your input", (done) => { + request(app) + .post("/bake") + .attach("input", `${testDataPath}/hex_input.txt`) + .expect(400, done); + }); + + it("should complain if it cannot find input field in form", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_single_op.json`) + .expect(400, done); + }); + + it("should complain if it cannot find a matching operation - single op recipe - input field", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_single_op_invalid.json`) + .field("input", "testing - one, two, three") + .expect(400, done); + }); + + it("should complain if it cannot find a matching operation - multi-op recipe - input field", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_multi_op_invalid.json`) + .field("input", "testing - one, two, three") + .expect(400, done); + }); + + it("should complain if the recipe file is not valid JSON", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_invalid_json.json`) + .field("input", "testing - one, two, three") + .expect(400, done); + }); + + it("should take input as a multipart field", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_single_op.json`) + .field("input", "The crowds stared around wildly") + .expect(200, done); + }); + + it("should take input as a multipart file upload", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_single_op.json`) + .attach("input", `${testDataPath}/hex_input.txt`) + .expect(200, done); + }); + + it("should perform a simple recipe with input as a field", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_to_morse.json`) + .field("input", "The crowds stared around wildly") + .expect(200) + .expect({ + value: `- .... . +-.-. .-. --- .-- -.. ... +... - .- .-. . -.. +.- .-. --- ..- -. -.. +.-- .. .-.. -.. .-.. -.--`, + type: "string", + }, done); + }); + + it("should perform a simple recipe with input as a file", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_to_morse.json`) + .attach("input", `${testDataPath}/text_input.txt`) + .expect(200) + .expect({ + value: `- .... . +-.-. .-. --- .-- -.. ... +... - .- .-. . -.. +.- .-. --- ..- -. -.. +.-- .. .-.. -.. .-.. -.--`, + type: "string", + }, done); + }); + + it("should bake a multi-op recipe with arguments", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_multi_op_with_args.json`) + .field("input", "some input") + .expect(200) + .expect({ + value: "begin_something_anananaaaaak_da_aaak_da_aaaaananaaaaaaan_da_aaaaaaanan_da_aaak_end_something", + type: "string", + }, done); + }); + + it("should handle image files as input", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_detect_file_type.json`) + .attach("input", `${testDataPath}/chef.png`) + .expect(200) + .expect({ + type: "string", + value: `File type: Portable Network Graphics image +Extension: png +MIME type: image/png +` + }, done); + }); + + it("should optionally transform the output to outputType", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_take_bytes.json`) + .attach("input", `${testDataPath}/text_input.txt`) + .field("outputType", "string") + .expect(200) + .expect({ + type: "string", + value: "The c" + }, done); + }); + + it("should optionally transform the output to outputType, when outputType is an enum", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_take_bytes.json`) + .attach("input", `${testDataPath}/text_input.txt`) + .field("outputType", 1) + .expect(200) + .expect({ + type: "string", + value: "The c" + }, done); + }); + + it("should set content-disposition header if File outputType is requested", (done) => { + request(app) + .post("/bake") + .attach("recipe", `${testDataPath}/recipe_take_bytes.json`) + .attach("input", `${testDataPath}/text_input.txt`) + .field("outputType", "file") + .expect(200) + .expect("Content-Disposition", "attachment") + .end((err, res) => { + if (err) return done(err); + assert.deepEqual(res.body.type, "File"); + assert.deepEqual(res.body.value.name, "unknown"); + assert.deepEqual(res.body.value.data.data, [84, 104, 101, 32, 99]); + return done(); + }); + }); + +}); diff --git a/test/test_data/chef.png b/test/test_data/chef.png new file mode 100644 index 0000000..5880f33 Binary files /dev/null and b/test/test_data/chef.png differ diff --git a/test/test_data/hex_input.txt b/test/test_data/hex_input.txt new file mode 100644 index 0000000..3346d74 --- /dev/null +++ b/test/test_data/hex_input.txt @@ -0,0 +1 @@ +54 68 65 20 63 72 6f 77 64 73 20 73 74 61 72 65 64 20 61 72 6f 75 6e 64 20 77 69 6c 64 6c 79 diff --git a/test/test_data/recipe_detect_file_type.json b/test/test_data/recipe_detect_file_type.json new file mode 100644 index 0000000..8617823 --- /dev/null +++ b/test/test_data/recipe_detect_file_type.json @@ -0,0 +1,3 @@ +[ + "detect file type" +] diff --git a/test/test_data/recipe_invalid_json.json b/test/test_data/recipe_invalid_json.json new file mode 100644 index 0000000..9585b22 --- /dev/null +++ b/test/test_data/recipe_invalid_json.json @@ -0,0 +1 @@ +{ 1234567 diff --git a/test/test_data/recipe_multi_op_invalid.json b/test/test_data/recipe_multi_op_invalid.json new file mode 100644 index 0000000..2e5caaf --- /dev/null +++ b/test/test_data/recipe_multi_op_invalid.json @@ -0,0 +1,4 @@ +[ + "to hex", + "not a valid operation" +] diff --git a/test/test_data/recipe_multi_op_with_args.json b/test/test_data/recipe_multi_op_with_args.json new file mode 100644 index 0000000..baca24b --- /dev/null +++ b/test/test_data/recipe_multi_op_with_args.json @@ -0,0 +1,22 @@ +[ + { + "op": "To Morse Code", + "args": [ + "Dash/Dot", + "Backslash", + "Comma" + ] + }, + { + "op": "Hex to PEM", + "args": [ + "SOMETHING" + ] + }, + { + "op": "To Snake case", + "args": [ + false + ] + } +] diff --git a/test/test_data/recipe_single_op.json b/test/test_data/recipe_single_op.json new file mode 100644 index 0000000..34db753 --- /dev/null +++ b/test/test_data/recipe_single_op.json @@ -0,0 +1,3 @@ +[ + "from hex" +] diff --git a/test/test_data/recipe_single_op_invalid.json b/test/test_data/recipe_single_op_invalid.json new file mode 100644 index 0000000..a21dd9f --- /dev/null +++ b/test/test_data/recipe_single_op_invalid.json @@ -0,0 +1 @@ +["not an operation"] diff --git a/test/test_data/recipe_take_bytes.json b/test/test_data/recipe_take_bytes.json new file mode 100644 index 0000000..5549303 --- /dev/null +++ b/test/test_data/recipe_take_bytes.json @@ -0,0 +1,3 @@ +[ + "take bytes" +] diff --git a/test/test_data/recipe_to_morse.json b/test/test_data/recipe_to_morse.json new file mode 100644 index 0000000..97ea3af --- /dev/null +++ b/test/test_data/recipe_to_morse.json @@ -0,0 +1,3 @@ +[ + "to morse code" +] diff --git a/test/test_data/text_input.txt b/test/test_data/text_input.txt new file mode 100644 index 0000000..7cd2fac --- /dev/null +++ b/test/test_data/text_input.txt @@ -0,0 +1 @@ +The crowds stared around wildly \ No newline at end of file