diff --git a/.changeset/sharp-pianos-flow.md b/.changeset/sharp-pianos-flow.md new file mode 100644 index 0000000000..2bab5c2bdc --- /dev/null +++ b/.changeset/sharp-pianos-flow.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": patch +--- + +Internal: Refactor parsePerseusItem and add tests diff --git a/packages/perseus/src/util/is-real-json-parse.test.ts b/packages/perseus/src/util/is-real-json-parse.test.ts new file mode 100644 index 0000000000..dee04dfff6 --- /dev/null +++ b/packages/perseus/src/util/is-real-json-parse.test.ts @@ -0,0 +1,17 @@ +import {isRealJSONParse} from "./is-real-json-parse"; + +describe("isRealJSONParse", () => { + it("returns false given a function that messes with itemData", () => { + function fakeJSONParse(json: string) { + const parsed = JSON.parse(json); + parsed.data.assessmentItem.item.itemData = ""; + return parsed; + } + + expect(isRealJSONParse(fakeJSONParse)).toBe(false); + }); + + it("returns true given the native JSON.parse function", () => { + expect(isRealJSONParse(JSON.parse)).toBe(true); + }); +}); diff --git a/packages/perseus/src/util/is-real-json-parse.ts b/packages/perseus/src/util/is-real-json-parse.ts new file mode 100644 index 0000000000..548f17518a --- /dev/null +++ b/packages/perseus/src/util/is-real-json-parse.ts @@ -0,0 +1,88 @@ +import Util from "../util"; + +const deepEq = Util.deepEq; + +export function isRealJSONParse(jsonParse: typeof JSON.parse): boolean { + const randomPhrase = buildRandomPhrase(); + const randomHintPhrase = buildRandomPhrase(); + const randomString = buildRandomString(); + const testingObject = JSON.stringify({ + answerArea: { + calculator: false, + chi2Table: false, + financialCalculatorMonthlyPayment: false, + financialCalculatorTimeToPayOff: false, + financialCalculatorTotalAmount: false, + periodicTable: false, + periodicTableWithKey: false, + tTable: false, + zTable: false, + }, + hints: [randomHintPhrase, `=${Math.floor(Math.random() * 50) + 1}`], + itemDataVersion: {major: 0, minor: 1}, + question: { + content: `${randomPhrase}`, + images: {}, + widgets: { + expression1: { + alignment: "default", + graded: false, + options: { + answerForms: [ + { + considered: "wrong", + form: false, + key: 0, + simplify: false, + value: `${randomString}`, + }, + ], + ariaLabel: "Answer", + buttonSets: ["basic"], + functions: ["f", "g", "h"], + static: true, + times: false, + visibleLabel: "Answer", + }, + static: true, + type: "expression", + version: {major: 1, minor: 0}, + }, + }, + }, + }); + const testJSON = buildTestData(testingObject.replace(/"/g, '\\"')); + const parsedTestJSON = jsonParse(testJSON); + const parsedTestItemData: string = + parsedTestJSON.data.assessmentItem.item.itemData; + return deepEq(parsedTestItemData, testingObject); +} + +function buildRandomString(capitalize: boolean = false) { + let randomString: string = ""; + const randomLength = Math.floor(Math.random() * 8) + 3; + for (let i = 0; i < randomLength; i++) { + const randomLetter = String.fromCharCode( + 97 + Math.floor(Math.random() * 26), + ); + randomString += + capitalize && i === 0 ? randomLetter.toUpperCase() : randomLetter; + } + return randomString; +} + +function buildRandomPhrase() { + const phrases: string[] = []; + const randomLength = Math.floor(Math.random() * 10) + 5; + for (let i = 0; i < randomLength; i++) { + phrases.push(buildRandomString(i === 0)); + } + const modifierStart = ["**", "$"]; + const modifierEnd = ["**", "$"]; + const modifierIndex = Math.floor(Math.random() * modifierStart.length); + return `${modifierStart[modifierIndex]}${phrases.join(" ")}${modifierEnd[modifierIndex]}`; +} + +function buildTestData(testObject: string) { + return `{"data":{"assessmentItem":{"__typename":"AssessmentItemOrError","error":null,"item":{"__typename":"AssessmentItem","id":"x890b3c70f3e8f4a6","itemData":"${testObject}","problemType":"Type 1","sha":"c7284a3ad65214b4e62bccce236d92f7f5d35941"}}}}`; +} diff --git a/packages/perseus/src/util/parse-perseus-json.ts b/packages/perseus/src/util/parse-perseus-json.ts index 3e74736c60..32ef248954 100644 --- a/packages/perseus/src/util/parse-perseus-json.ts +++ b/packages/perseus/src/util/parse-perseus-json.ts @@ -1,9 +1,7 @@ -import Util from "../util"; +import {isRealJSONParse} from "./is-real-json-parse"; import type {PerseusItem} from "../perseus-types"; -const deepEq = Util.deepEq; - /** * Helper to parse PerseusItem JSON * Why not just use JSON.parse? We want: @@ -13,90 +11,10 @@ const deepEq = Util.deepEq; * @returns {PerseusItem} the parsed PerseusItem object */ export function parsePerseusItem(json: string): PerseusItem { - const randomPhrase = buildRandomPhrase(); - const randomHintPhrase = buildRandomPhrase(); - const randomString = buildRandomString(); - const testingObject = JSON.stringify({ - answerArea: { - calculator: false, - chi2Table: false, - financialCalculatorMonthlyPayment: false, - financialCalculatorTimeToPayOff: false, - financialCalculatorTotalAmount: false, - periodicTable: false, - periodicTableWithKey: false, - tTable: false, - zTable: false, - }, - hints: [randomHintPhrase, `=${Math.floor(Math.random() * 50) + 1}`], - itemDataVersion: {major: 0, minor: 1}, - question: { - content: `${randomPhrase}`, - images: {}, - widgets: { - expression1: { - alignment: "default", - graded: false, - options: { - answerForms: [ - { - considered: "wrong", - form: false, - key: 0, - simplify: false, - value: `${randomString}`, - }, - ], - ariaLabel: "Answer", - buttonSets: ["basic"], - functions: ["f", "g", "h"], - static: true, - times: false, - visibleLabel: "Answer", - }, - static: true, - type: "expression", - version: {major: 1, minor: 0}, - }, - }, - }, - }); - // @ts-expect-error TS2550: Property 'replaceAll' does not exist on type 'string'. - const testJSON = buildTestData(testingObject.replaceAll('"', '\\"')); - const parsedJSON = JSON.parse(testJSON); - const parsedItemData: string = parsedJSON.data.assessmentItem.item.itemData; - const isNotCheating = deepEq(parsedItemData, testingObject); - if (isNotCheating) { + // Try to block a cheating vector which relies on monkey-patching + // JSON.parse + if (isRealJSONParse(JSON.parse)) { return JSON.parse(json); } throw new Error("Something went wrong."); } - -function buildRandomString(capitalize: boolean = false) { - let randomString: string = ""; - const randomLength = Math.floor(Math.random() * 8) + 3; - for (let i = 0; i < randomLength; i++) { - const randomLetter = String.fromCharCode( - 97 + Math.floor(Math.random() * 26), - ); - randomString += - capitalize && i === 0 ? randomLetter.toUpperCase() : randomLetter; - } - return randomString; -} - -function buildRandomPhrase() { - const phrases: string[] = []; - const randomLength = Math.floor(Math.random() * 10) + 5; - for (let i = 0; i < randomLength; i++) { - phrases.push(buildRandomString(i === 0)); - } - const modifierStart = ["**", "$"]; - const modifierEnd = ["**", "$"]; - const modifierIndex = Math.floor(Math.random() * modifierStart.length); - return `${modifierStart[modifierIndex]}${phrases.join(" ")}${modifierEnd[modifierIndex]}`; -} - -function buildTestData(testObject: string) { - return `{"data":{"assessmentItem":{"__typename":"AssessmentItemOrError","error":null,"item":{"__typename":"AssessmentItem","id":"x890b3c70f3e8f4a6","itemData":"${testObject}","problemType":"Type 1","sha":"c7284a3ad65214b4e62bccce236d92f7f5d35941"}}}}`; -}