diff --git a/frontend/package.json b/frontend/package.json index 48cdc81b6a28..9461dadb6888 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,16 @@ "not op_mini all", "not dead" ], + "lint-staged": { + "*.{json,scss,css,html}": [ + "prettier --write" + ], + "*.{ts,js}": [ + "prettier --write", + "oxlint", + "eslint" + ] + }, "devDependencies": { "@fortawesome/fontawesome-free": "5.15.4", "@monkeytype/eslint-config": "workspace:*", diff --git a/frontend/scripts/language-hashes.ts b/frontend/scripts/language-hashes.ts new file mode 100644 index 000000000000..9b4e2320030b --- /dev/null +++ b/frontend/scripts/language-hashes.ts @@ -0,0 +1,66 @@ +import { Plugin } from "vite"; +import { readdirSync, readFileSync } from "fs"; +import { TextEncoder } from "util"; +import { createHash } from "crypto"; + +const virtualModuleId = "virtual:language-hashes"; +const resolvedVirtualModuleId = "\0" + virtualModuleId; +let skip = false; + +export function languageHashes(): Plugin { + return { + name: "virtual-language-hashes", + resolveId(id) { + if (id === virtualModuleId) return resolvedVirtualModuleId; + return; + }, + load(id) { + if (id === resolvedVirtualModuleId) { + const hashes: Record = skip ? {} : getHashes(); + return ` + export const languageHashes = ${JSON.stringify(hashes)}; + `; + } + return; + }, + configResolved(resolvedConfig) { + if (resolvedConfig?.define?.["IS_DEVELOPMENT"] === "true") { + skip = true; + console.log("Skipping language hashing in dev environment."); + } + }, + }; +} + +function getHashes(): Record { + const start = performance.now(); + + console.log("\nHashing languages..."); + + const hashes = Object.fromEntries( + readdirSync("./static/languages").map((file) => { + return [file.slice(0, -5), calcHash(file)]; + }) + ); + + const end = performance.now(); + + console.log(`Creating language hashes took ${Math.round(end - start)} ms`); + + return hashes; +} + +function calcHash(file: string): string { + const currentLanguage = JSON.stringify( + JSON.parse(readFileSync("./static/languages/" + file).toString()), + null, + 0 + ); + const encoder = new TextEncoder(); + const data = encoder.encode(currentLanguage); + return createHash("sha256").update(data).digest("hex"); +} + +if (import.meta.url.endsWith(process.argv[1] as string)) { + console.log(JSON.stringify(getHashes(), null, 4)); +} diff --git a/frontend/src/ts/module.d.ts b/frontend/src/ts/module.d.ts new file mode 100644 index 000000000000..a30b0327be4f --- /dev/null +++ b/frontend/src/ts/module.d.ts @@ -0,0 +1,5 @@ +import { Language } from "@monkeytype/schemas/languages"; + +declare module "virtual:language-hashes" { + export const languageHashes: Record; +} diff --git a/frontend/src/ts/sentry.ts b/frontend/src/ts/sentry.ts index f657259308b5..5ac6d5eb600a 100644 --- a/frontend/src/ts/sentry.ts +++ b/frontend/src/ts/sentry.ts @@ -26,10 +26,10 @@ export async function activateSentry(): Promise { environment: envConfig.isDevelopment ? "development" : "production", integrations: [ Sentry.browserTracingIntegration(), - Sentry.replayIntegration({ - unmask: ["#notificationCenter"], - block: ["#commandLine .modal .suggestions"], - }), + // Sentry.replayIntegration({ + // unmask: ["#notificationCenter"], + // block: ["#commandLine .modal .suggestions"], + // }), Sentry.thirdPartyErrorFilterIntegration({ filterKeys: ["monkeytype-frontend"], // Defines how to handle errors that contain third party stack frames. diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 331f3773e156..0a4780b2bb21 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -456,7 +456,6 @@ async function init(): Promise { } if (!language || language.name !== Config.language) { - UpdateConfig.setLanguage("english"); return await init(); } diff --git a/frontend/src/ts/utils/json-data.ts b/frontend/src/ts/utils/json-data.ts index 4dca4a780c59..706c8d9fb8f8 100644 --- a/frontend/src/ts/utils/json-data.ts +++ b/frontend/src/ts/utils/json-data.ts @@ -1,9 +1,13 @@ import { Language, LanguageObject } from "@monkeytype/schemas/languages"; import { Challenge } from "@monkeytype/schemas/challenges"; import { LayoutObject } from "@monkeytype/schemas/layouts"; +import { toHex } from "./strings"; +import { languageHashes } from "virtual:language-hashes"; +import { isDevEnvironment } from "./misc"; //pin implementation const fetch = window.fetch; +const cryptoSubtle = window.crypto.subtle; /** * Fetches JSON data from the specified URL using the fetch API. @@ -96,9 +100,23 @@ let currentLanguage: LanguageObject; export async function getLanguage(lang: Language): Promise { // try { if (currentLanguage === undefined || currentLanguage.name !== lang) { - currentLanguage = await cachedFetchJson( + const loaded = await cachedFetchJson( `/languages/${lang}.json` ); + + if (!isDevEnvironment()) { + //check the content to make it less easy to manipulate + const encoder = new TextEncoder(); + const data = encoder.encode(JSON.stringify(loaded, null, 0)); + const hashBuffer = await cryptoSubtle.digest("SHA-256", data); + const hash = toHex(hashBuffer); + if (hash !== languageHashes[lang]) { + throw new Error( + "Integrity check failed. Try refreshing the page. If this error persists, please contact support." + ); + } + } + currentLanguage = loaded; } return currentLanguage; } diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 22e567dd6f81..cbe08ce29d9f 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -296,6 +296,20 @@ export function areCharactersVisuallyEqual( return false; } +export function toHex(buffer: ArrayBuffer): string { + // @ts-expect-error modern browsers + if (Uint8Array.prototype.toHex !== undefined) { + // @ts-expect-error modern browsers + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return new Uint8Array(buffer).toHex() as string; + } + const hashArray = Array.from(new Uint8Array(buffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return hashHex; +} + // Export testing utilities for unit tests export const __testing = { hasRTLCharacters, diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index d4de7464f676..914917de4f39 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -7,7 +7,10 @@ "module": "ESNext", "allowUmdGlobalAccess": true, "target": "ES6", - "noEmit": true + "noEmit": true, + "paths": { + "virtual:language-hashes": ["./src/ts/module.d.ts"] + } }, "include": ["./src/**/*.ts", "./scripts/**/*.ts"], "exclude": ["node_modules", "build", "setup-tests.ts", "**/*.spec.ts"] diff --git a/frontend/vite.config.js b/frontend/vite.config.js index bf2572157c8f..11ce0ba5c726 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,10 +6,12 @@ import PROD_CONFIG from "./vite.config.prod"; import DEV_CONFIG from "./vite.config.dev"; import MagicString from "magic-string"; import { Fonts } from "./src/ts/constants/fonts"; +import { languageHashes } from "./scripts/language-hashes"; /** @type {import("vite").UserConfig} */ const BASE_CONFIG = { plugins: [ + languageHashes(), { name: "simple-jquery-inject", async transform(src, id) { diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 225a9bfb7bca..b8f4993846fd 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "vitest/config"; +import { languageHashes } from "./scripts/language-hashes"; export default defineConfig({ test: { @@ -17,4 +18,6 @@ export default defineConfig({ }, }, }, + + plugins: [languageHashes()], });