Skip to content

Commit 70dfd2a

Browse files
fehmerMiodec
andauthored
chore: language integrity check (@fehmer) (#7074)
Co-authored-by: Miodec <jack@monkeytype.com>
1 parent bf37029 commit 70dfd2a

File tree

9 files changed

+123
-3
lines changed

9 files changed

+123
-3
lines changed

frontend/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@
3131
"not op_mini all",
3232
"not dead"
3333
],
34+
"lint-staged": {
35+
"*.{json,scss,css,html}": [
36+
"prettier --write"
37+
],
38+
"*.{ts,js}": [
39+
"prettier --write",
40+
"oxlint",
41+
"eslint"
42+
]
43+
},
3444
"devDependencies": {
3545
"@fortawesome/fontawesome-free": "5.15.4",
3646
"@monkeytype/eslint-config": "workspace:*",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Plugin } from "vite";
2+
import { readdirSync, readFileSync } from "fs";
3+
import { TextEncoder } from "util";
4+
import { createHash } from "crypto";
5+
6+
const virtualModuleId = "virtual:language-hashes";
7+
const resolvedVirtualModuleId = "\0" + virtualModuleId;
8+
let skip = false;
9+
10+
export function languageHashes(): Plugin {
11+
return {
12+
name: "virtual-language-hashes",
13+
resolveId(id) {
14+
if (id === virtualModuleId) return resolvedVirtualModuleId;
15+
return;
16+
},
17+
load(id) {
18+
if (id === resolvedVirtualModuleId) {
19+
const hashes: Record<string, string> = skip ? {} : getHashes();
20+
return `
21+
export const languageHashes = ${JSON.stringify(hashes)};
22+
`;
23+
}
24+
return;
25+
},
26+
configResolved(resolvedConfig) {
27+
if (resolvedConfig?.define?.["IS_DEVELOPMENT"] === "true") {
28+
skip = true;
29+
console.log("Skipping language hashing in dev environment.");
30+
}
31+
},
32+
};
33+
}
34+
35+
function getHashes(): Record<string, string> {
36+
const start = performance.now();
37+
38+
console.log("\nHashing languages...");
39+
40+
const hashes = Object.fromEntries(
41+
readdirSync("./static/languages").map((file) => {
42+
return [file.slice(0, -5), calcHash(file)];
43+
})
44+
);
45+
46+
const end = performance.now();
47+
48+
console.log(`Creating language hashes took ${Math.round(end - start)} ms`);
49+
50+
return hashes;
51+
}
52+
53+
function calcHash(file: string): string {
54+
const currentLanguage = JSON.stringify(
55+
JSON.parse(readFileSync("./static/languages/" + file).toString()),
56+
null,
57+
0
58+
);
59+
const encoder = new TextEncoder();
60+
const data = encoder.encode(currentLanguage);
61+
return createHash("sha256").update(data).digest("hex");
62+
}
63+
64+
if (import.meta.url.endsWith(process.argv[1] as string)) {
65+
console.log(JSON.stringify(getHashes(), null, 4));
66+
}

frontend/src/ts/module.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Language } from "@monkeytype/schemas/languages";
2+
3+
declare module "virtual:language-hashes" {
4+
export const languageHashes: Record<Language, string>;
5+
}

frontend/src/ts/test/test-logic.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,6 @@ async function init(): Promise<boolean> {
456456
}
457457

458458
if (!language || language.name !== Config.language) {
459-
UpdateConfig.setLanguage("english");
460459
return await init();
461460
}
462461

frontend/src/ts/utils/json-data.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { Language, LanguageObject } from "@monkeytype/schemas/languages";
22
import { Challenge } from "@monkeytype/schemas/challenges";
33
import { LayoutObject } from "@monkeytype/schemas/layouts";
4+
import { toHex } from "./strings";
5+
import { languageHashes } from "virtual:language-hashes";
6+
import { isDevEnvironment } from "./misc";
47

58
//pin implementation
69
const fetch = window.fetch;
10+
const cryptoSubtle = window.crypto.subtle;
711

812
/**
913
* Fetches JSON data from the specified URL using the fetch API.
@@ -96,9 +100,23 @@ let currentLanguage: LanguageObject;
96100
export async function getLanguage(lang: Language): Promise<LanguageObject> {
97101
// try {
98102
if (currentLanguage === undefined || currentLanguage.name !== lang) {
99-
currentLanguage = await cachedFetchJson<LanguageObject>(
103+
const loaded = await cachedFetchJson<LanguageObject>(
100104
`/languages/${lang}.json`
101105
);
106+
107+
if (!isDevEnvironment()) {
108+
//check the content to make it less easy to manipulate
109+
const encoder = new TextEncoder();
110+
const data = encoder.encode(JSON.stringify(loaded, null, 0));
111+
const hashBuffer = await cryptoSubtle.digest("SHA-256", data);
112+
const hash = toHex(hashBuffer);
113+
if (hash !== languageHashes[lang]) {
114+
throw new Error(
115+
"Integrity check failed. Try refreshing the page. If this error persists, please contact support."
116+
);
117+
}
118+
}
119+
currentLanguage = loaded;
102120
}
103121
return currentLanguage;
104122
}

frontend/src/ts/utils/strings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,20 @@ export function areCharactersVisuallyEqual(
296296
return false;
297297
}
298298

299+
export function toHex(buffer: ArrayBuffer): string {
300+
// @ts-expect-error modern browsers
301+
if (Uint8Array.prototype.toHex !== undefined) {
302+
// @ts-expect-error modern browsers
303+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
304+
return new Uint8Array(buffer).toHex() as string;
305+
}
306+
const hashArray = Array.from(new Uint8Array(buffer));
307+
const hashHex = hashArray
308+
.map((b) => b.toString(16).padStart(2, "0"))
309+
.join("");
310+
return hashHex;
311+
}
312+
299313
// Export testing utilities for unit tests
300314
export const __testing = {
301315
hasRTLCharacters,

frontend/tsconfig.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
"module": "ESNext",
88
"allowUmdGlobalAccess": true,
99
"target": "ES6",
10-
"noEmit": true
10+
"noEmit": true,
11+
"paths": {
12+
"virtual:language-hashes": ["./src/ts/module.d.ts"]
13+
}
1114
},
1215
"include": ["./src/**/*.ts", "./scripts/**/*.ts"],
1316
"exclude": ["node_modules", "build", "setup-tests.ts", "**/*.spec.ts"]

frontend/vite.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import PROD_CONFIG from "./vite.config.prod";
66
import DEV_CONFIG from "./vite.config.dev";
77
import MagicString from "magic-string";
88
import { Fonts } from "./src/ts/constants/fonts";
9+
import { languageHashes } from "./scripts/language-hashes";
910

1011
/** @type {import("vite").UserConfig} */
1112
const BASE_CONFIG = {
1213
plugins: [
14+
languageHashes(),
1315
{
1416
name: "simple-jquery-inject",
1517
async transform(src, id) {

frontend/vitest.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defineConfig } from "vitest/config";
2+
import { languageHashes } from "./scripts/language-hashes";
23

34
export default defineConfig({
45
test: {
@@ -17,4 +18,6 @@ export default defineConfig({
1718
},
1819
},
1920
},
21+
22+
plugins: [languageHashes()],
2023
});

0 commit comments

Comments
 (0)