Vendor dompurify, use isomorphic-dompurify only for tests

This commit is contained in:
Joseph Garrone 2024-09-22 20:12:11 +02:00
parent b6e9043d91
commit ddb0af1dcb
19 changed files with 1370 additions and 72 deletions

View File

@ -111,6 +111,11 @@
"evt": "^2.5.7", "evt": "^2.5.7",
"tsx": "^4.15.5", "tsx": "^4.15.5",
"html-entities": "^2.5.2", "html-entities": "^2.5.2",
"isomorphic-dompurify": "^2.15.0" "isomorphic-dompurify": "^2.15.0",
"dompurify": "^3.1.6",
"@types/dompurify": "^2.0.0",
"webpack": "5.93.0",
"webpack-cli": "5.1.4",
"@babel/preset-env": "7.24.8"
} }
} }

View File

@ -1,10 +1,4 @@
import * as child_process from "child_process"; import { run } from "./shared/run";
run("yarn build"); run("yarn build");
run("npx build-storybook"); run("npx build-storybook");
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -1,4 +1,3 @@
import * as child_process from "child_process";
import * as fs from "fs"; import * as fs from "fs";
import { join } from "path"; import { join } from "path";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
@ -6,6 +5,8 @@ import { transformCodebase } from "../../src/bin/tools/transformCodebase";
import { createPublicKeycloakifyDevResourcesDir } from "./createPublicKeycloakifyDevResourcesDir"; import { createPublicKeycloakifyDevResourcesDir } from "./createPublicKeycloakifyDevResourcesDir";
import { createAccountV1Dir } from "./createAccountV1Dir"; import { createAccountV1Dir } from "./createAccountV1Dir";
import chalk from "chalk"; import chalk from "chalk";
import { run } from "../shared/run";
import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
(async () => { (async () => {
console.log(chalk.cyan("Building Keycloakify...")); console.log(chalk.cyan("Building Keycloakify..."));
@ -88,6 +89,7 @@ import chalk from "chalk";
run(`npx tsc -p ${join("src", "tsconfig.json")}`); run(`npx tsc -p ${join("src", "tsconfig.json")}`);
run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`); run(`npx tsc-alias -p ${join("src", "tsconfig.json")}`);
vendorFrontendDependencies({ distDirPath: join("dist") });
if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) { if (fs.existsSync(join("dist", "vite-plugin", "index.original.js"))) {
fs.renameSync( fs.renameSync(
@ -164,12 +166,6 @@ import chalk from "chalk";
); );
})(); })();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit" });
}
function patchDeprecatedBufferApiUsage(filePath: string) { function patchDeprecatedBufferApiUsage(filePath: string) {
const before = fs.readFileSync(filePath).toString("utf8"); const before = fs.readFileSync(filePath).toString("utf8");

View File

@ -0,0 +1,100 @@
import * as fs from "fs";
import {
join as pathJoin,
relative as pathRelative,
basename as pathBasename,
dirname as pathDirname
} from "path";
import { assert } from "tsafe/assert";
import { run } from "../shared/run";
import { cacheDirPath as cacheDirPath_base } from "../shared/cacheDirPath";
export function vendorFrontendDependencies(params: { distDirPath: string }) {
const { distDirPath } = params;
const vendorDirPath = pathJoin(distDirPath, "tools", "vendor");
const cacheDirPath = pathJoin(cacheDirPath_base, "vendorFrontendDependencies");
const extraBundleFileBasenames = new Set<string>();
fs.readdirSync(vendorDirPath)
.filter(fileBasename => fileBasename.endsWith(".js"))
.map(fileBasename => pathJoin(vendorDirPath, fileBasename))
.forEach(filePath => {
{
const mapFilePath = `${filePath}.map`;
if (fs.existsSync(mapFilePath)) {
fs.unlinkSync(mapFilePath);
}
}
if (!fs.existsSync(cacheDirPath)) {
fs.mkdirSync(cacheDirPath, { recursive: true });
}
const webpackConfigJsFilePath = pathJoin(cacheDirPath, "webpack.config.js");
const webpackOutputDirPath = pathJoin(cacheDirPath, "webpack_output");
const webpackOutputFilePath = pathJoin(webpackOutputDirPath, "index.js");
fs.writeFileSync(
webpackConfigJsFilePath,
Buffer.from(
[
`const path = require('path');`,
``,
`module.exports = {`,
` mode: 'production',`,
` entry: '${filePath}',`,
` output: {`,
` path: '${webpackOutputDirPath}',`,
` filename: '${pathBasename(webpackOutputFilePath)}',`,
` libraryTarget: 'module',`,
` },`,
` target: "web",`,
` module: {`,
` rules: [`,
` {`,
` test: /\.js$/,`,
` use: {`,
` loader: 'babel-loader',`,
` options: {`,
` presets: ['@babel/preset-env'],`,
` }`,
` }`,
` }`,
` ]`,
` }`,
`};`
].join("\n")
)
);
run(
`npx webpack --config ${pathRelative(process.cwd(), webpackConfigJsFilePath)}`
);
fs.readdirSync(webpackOutputDirPath)
.filter(fileBasename => !fileBasename.endsWith(".txt"))
.map(fileBasename => pathJoin(webpackOutputDirPath, fileBasename))
.forEach(bundleFilePath => {
assert(bundleFilePath.endsWith(".js"));
if (pathBasename(bundleFilePath) === "index.js") {
fs.renameSync(webpackOutputFilePath, filePath);
} else {
const bundleFileBasename = pathBasename(bundleFilePath);
assert(!extraBundleFileBasenames.has(bundleFileBasename));
extraBundleFileBasenames.add(bundleFileBasename);
fs.renameSync(
bundleFilePath,
pathJoin(pathDirname(filePath), bundleFileBasename)
);
}
});
fs.rmSync(webpackOutputDirPath, { recursive: true });
});
}

View File

@ -6,6 +6,7 @@ import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred"; import { Deferred } from "evt/tools/Deferred";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
import { is } from "tsafe/is"; import { is } from "tsafe/is";
import { run } from "./shared/run";
(async () => { (async () => {
{ {
@ -84,9 +85,3 @@ import { is } from "tsafe/is";
console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`); console.log(`${chalk.green(`✓ Exported realm to`)} ${chalk.bold(targetFilePath)}`);
})(); })();
function run(command: string) {
console.log(chalk.grey(`$ ${command}`));
return child_process.execSync(command, { stdio: "inherit" });
}

View File

@ -1,4 +1,3 @@
import "minimal-polyfills/Object.fromEntries";
import * as fs from "fs"; import * as fs from "fs";
import { import {
join as pathJoin, join as pathJoin,

View File

@ -1,8 +1,8 @@
import * as child_process from "child_process";
import * as fs from "fs"; import * as fs from "fs";
import { join } from "path"; import { join } from "path";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange"; import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
import { crawl } from "../src/bin/tools/crawl"; import { crawl } from "../src/bin/tools/crawl";
import { run } from "./shared/run";
{ {
const dirPath = "node_modules"; const dirPath = "node_modules";
@ -47,9 +47,3 @@ run("yarn install", { cwd: join("..", starterName) });
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`); run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
startRebuildOnSrcChange(); startRebuildOnSrcChange();
function run(command: string, options?: { cwd: string }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -0,0 +1,9 @@
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
export const cacheDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"node_modules",
".cache",
"scripts"
);

View File

@ -2,8 +2,9 @@ import { relative as pathRelative } from "path";
import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive"; import { downloadAndExtractArchive } from "../../src/bin/tools/downloadAndExtractArchive";
import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions"; import { getProxyFetchOptions } from "../../src/bin/tools/fetchProxyOptions";
import { join as pathJoin } from "path"; import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
import { assert, type Equals } from "tsafe/assert"; import { assert, type Equals } from "tsafe/assert";
import { cacheDirPath } from "./cacheDirPath";
import { getThisCodebaseRootDirPath } from "../../src/bin/tools/getThisCodebaseRootDirPath";
const KEYCLOAK_VERSION = { const KEYCLOAK_VERSION = {
FOR_LOGIN_THEME: "25.0.4", FOR_LOGIN_THEME: "25.0.4",
@ -22,12 +23,7 @@ export async function downloadKeycloakDefaultTheme(params: {
const { extractedDirPath } = await downloadAndExtractArchive({ const { extractedDirPath } = await downloadAndExtractArchive({
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`, url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: pathJoin( cacheDirPath,
getThisCodebaseRootDirPath(),
"node_modules",
".cache",
"scripts"
),
fetchOptions: getProxyFetchOptions({ fetchOptions: getProxyFetchOptions({
npmConfigGetCwd: getThisCodebaseRootDirPath() npmConfigGetCwd: getThisCodebaseRootDirPath()
}), }),

8
scripts/shared/run.ts Normal file
View File

@ -0,0 +1,8 @@
import * as child_process from "child_process";
import chalk from "chalk";
export function run(command: string, options?: { cwd: string }) {
console.log(chalk.grey(`$ ${command}`));
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -1,5 +1,6 @@
import * as child_process from "child_process"; import * as child_process from "child_process";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange"; import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
import { run } from "./shared/run";
(async () => { (async () => {
run("yarn build"); run("yarn build");
@ -18,9 +19,3 @@ import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
startRebuildOnSrcChange(); startRebuildOnSrcChange();
})(); })();
function run(command: string, options?: { env?: NodeJS.ProcessEnv }) {
console.log(`$ ${command}`);
child_process.execSync(command, { stdio: "inherit", ...options });
}

View File

@ -1,4 +1,4 @@
import { DOMPurify } from "keycloakify/lib/vendor/isomorphic-dompurify"; import { DOMPurify } from "keycloakify/tools/vendor/dompurify";
type TagType = { type TagType = {
name: string; name: string;
@ -22,6 +22,16 @@ export class HtmlPolicyBuilder {
private isStylingAllowed: boolean = false; private isStylingAllowed: boolean = false;
private allowedProtocols: Set<string> = new Set(); private allowedProtocols: Set<string> = new Set();
private enforceRelNofollow: boolean = false; private enforceRelNofollow: boolean = false;
private DOMPurify: typeof DOMPurify;
// add a constructor
constructor(
dependencyInjections: Partial<{
DOMPurify: typeof DOMPurify;
}>
) {
this.DOMPurify = dependencyInjections.DOMPurify ?? DOMPurify;
}
allowWithoutAttributes(tag: string): this { allowWithoutAttributes(tag: string): this {
this.tagsAllowedWithNoAttribute.add(tag); this.tagsAllowedWithNoAttribute.add(tag);
@ -69,7 +79,10 @@ export class HtmlPolicyBuilder {
onElements(...tags: string[]): this { onElements(...tags: string[]): this {
if (this.currentAttribute) { if (this.currentAttribute) {
tags.forEach(tag => { tags.forEach(tag => {
const element = this.tagsAllowed.get(tag) || { name: tag, attributes: [] }; const element = this.tagsAllowed.get(tag) || {
name: tag,
attributes: []
};
element.attributes.push(this.currentAttribute!); element.attributes.push(this.currentAttribute!);
this.tagsAllowed.set(tag, element); this.tagsAllowed.set(tag, element);
}); });
@ -104,10 +117,10 @@ export class HtmlPolicyBuilder {
apply(html: string): string { apply(html: string): string {
//Clear all previous configs first ( in case we used DOMPurify somewhere else ) //Clear all previous configs first ( in case we used DOMPurify somewhere else )
DOMPurify.clearConfig(); this.DOMPurify.clearConfig();
DOMPurify.removeAllHooks(); this.DOMPurify.removeAllHooks();
this.setupHooks(); this.setupHooks();
return DOMPurify.sanitize(html, { return this.DOMPurify.sanitize(html, {
ALLOWED_TAGS: Array.from(this.tagsAllowed.keys()), ALLOWED_TAGS: Array.from(this.tagsAllowed.keys()),
ALLOWED_ATTR: this.getAllowedAttributes(), ALLOWED_ATTR: this.getAllowedAttributes(),
ALLOWED_URI_REGEXP: this.getAllowedUriRegexp(), ALLOWED_URI_REGEXP: this.getAllowedUriRegexp(),
@ -118,7 +131,7 @@ export class HtmlPolicyBuilder {
private setupHooks(): void { private setupHooks(): void {
// Check allowed attribute and global attributes and it doesnt exist in them remove it // Check allowed attribute and global attributes and it doesnt exist in them remove it
DOMPurify.addHook("uponSanitizeAttribute", (currentNode, hookEvent) => { this.DOMPurify.addHook("uponSanitizeAttribute", (currentNode, hookEvent) => {
if (!hookEvent) return; if (!hookEvent) return;
const tagName = currentNode.tagName.toLowerCase(); const tagName = currentNode.tagName.toLowerCase();
@ -142,16 +155,24 @@ export class HtmlPolicyBuilder {
currentNode.removeAttribute(hookEvent.attrName); currentNode.removeAttribute(hookEvent.attrName);
return; return;
} else { } else {
const attributeType = allowedAttributes.find(attr => attr.name === hookEvent.attrName); const attributeType = allowedAttributes.find(
attr => attr.name === hookEvent.attrName
);
if (attributeType) { if (attributeType) {
//Check if attribute value is allowed //Check if attribute value is allowed
if (attributeType.matchRegex && !attributeType.matchRegex.test(hookEvent.attrValue)) { if (
attributeType.matchRegex &&
!attributeType.matchRegex.test(hookEvent.attrValue)
) {
hookEvent.forceKeepAttr = false; hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false; hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName); currentNode.removeAttribute(hookEvent.attrName);
return; return;
} }
if (attributeType.matchFunction && !attributeType.matchFunction(hookEvent.attrValue)) { if (
attributeType.matchFunction &&
!attributeType.matchFunction(hookEvent.attrValue)
) {
hookEvent.forceKeepAttr = false; hookEvent.forceKeepAttr = false;
hookEvent.keepAttr = false; hookEvent.keepAttr = false;
currentNode.removeAttribute(hookEvent.attrName); currentNode.removeAttribute(hookEvent.attrName);
@ -168,9 +189,12 @@ export class HtmlPolicyBuilder {
} }
}); });
DOMPurify.addHook("afterSanitizeAttributes", currentNode => { this.DOMPurify.addHook("afterSanitizeAttributes", currentNode => {
// if tag is not allowed to have no attribute then remove it completely // if tag is not allowed to have no attribute then remove it completely
if (currentNode.attributes.length == 0 && currentNode.childNodes.length == 0) { if (
currentNode.attributes.length == 0 &&
currentNode.childNodes.length == 0
) {
if (!this.tagsAllowedWithNoAttribute.has(currentNode.tagName)) { if (!this.tagsAllowedWithNoAttribute.has(currentNode.tagName)) {
currentNode.remove(); currentNode.remove();
} }
@ -180,7 +204,10 @@ export class HtmlPolicyBuilder {
if (currentNode.attributes.length == 0) { if (currentNode.attributes.length == 0) {
//add currentNode children to parent node //add currentNode children to parent node
while (currentNode.firstChild) { while (currentNode.firstChild) {
currentNode?.parentNode?.insertBefore(currentNode.firstChild, currentNode); currentNode?.parentNode?.insertBefore(
currentNode.firstChild,
currentNode
);
} }
// Remove the currentNode itself // Remove the currentNode itself
currentNode.remove(); currentNode.remove();
@ -191,8 +218,13 @@ export class HtmlPolicyBuilder {
if (this.enforceRelNofollow) { if (this.enforceRelNofollow) {
if (!currentNode.hasAttribute("rel")) { if (!currentNode.hasAttribute("rel")) {
currentNode.setAttribute("rel", "nofollow"); currentNode.setAttribute("rel", "nofollow");
} else if (!currentNode.getAttribute("rel")?.includes("nofollow")) { } else if (
currentNode.setAttribute("rel", currentNode.getAttribute("rel") + " nofollow"); !currentNode.getAttribute("rel")?.includes("nofollow")
) {
currentNode.setAttribute(
"rel",
currentNode.getAttribute("rel") + " nofollow"
);
} }
} }
} }

View File

@ -1,4 +1,5 @@
import { KcSanitizerPolicy } from "./KcSanitizerPolicy"; import { KcSanitizerPolicy } from "./KcSanitizerPolicy";
import type { DOMPurify as ofTypeDomPurify } from "keycloakify/tools/vendor/dompurify";
// implementation of keycloak java sanitize method ( KeycloakSanitizerMethod ) // implementation of keycloak java sanitize method ( KeycloakSanitizerMethod )
// https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerMethod.java#L33 // https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/main/java/org/keycloak/theme/KeycloakSanitizerMethod.java#L33
@ -6,11 +7,20 @@ export class KcSanitizer {
private static HREF_PATTERN = /\s+href="([^"]*)"/g; private static HREF_PATTERN = /\s+href="([^"]*)"/g;
private static textarea: HTMLTextAreaElement | null = null; private static textarea: HTMLTextAreaElement | null = null;
public static sanitize(html: string, decodeHtml?: (html: string) => string): string { public static sanitize(
html: string,
dependencyInjections: Partial<{
DOMPurify: typeof ofTypeDomPurify;
htmlEntitiesDecode: (html: string) => string;
}>
): string {
if (html === "") return ""; if (html === "") return "";
html = decodeHtml !== undefined ? decodeHtml(html) : this.decodeHtml(html); html =
const sanitized = KcSanitizerPolicy.sanitize(html); dependencyInjections?.htmlEntitiesDecode !== undefined
? dependencyInjections.htmlEntitiesDecode(html)
: this.decodeHtml(html);
const sanitized = KcSanitizerPolicy.sanitize(html, dependencyInjections);
return this.fixURLs(sanitized); return this.fixURLs(sanitized);
} }

View File

@ -1,4 +1,5 @@
import { HtmlPolicyBuilder } from "keycloakify/tools/kcSanitize/HtmlPolicyBuilder"; import { HtmlPolicyBuilder } from "./HtmlPolicyBuilder";
import type { DOMPurify as ofTypeDomPurify } from "keycloakify/tools/vendor/dompurify";
//implementation of java Sanitizer policy ( KeycloakSanitizerPolicy ) //implementation of java Sanitizer policy ( KeycloakSanitizerPolicy )
// All regex directly copied from the keycloak source but some of them changed slightly to work with typescript(ONSITE_URL and OFFSITE_URL) // All regex directly copied from the keycloak source but some of them changed slightly to work with typescript(ONSITE_URL and OFFSITE_URL)
@ -76,8 +77,13 @@ export class KcSanitizerPolicy {
); );
} }
public static sanitize(html: string) { public static sanitize(
return new HtmlPolicyBuilder() html: string,
dependencyInjections: Partial<{
DOMPurify: typeof ofTypeDomPurify;
}>
): string {
return new HtmlPolicyBuilder(dependencyInjections)
.allowWithoutAttributes("span") .allowWithoutAttributes("span")
.allowAttributes("id") .allowAttributes("id")

View File

@ -1,5 +1,5 @@
import { KcSanitizer } from "./KcSanitizer"; import { KcSanitizer } from "./KcSanitizer";
export function kcSanitize(html: string): string { export function kcSanitize(html: string): string {
return KcSanitizer.sanitize(html); return KcSanitizer.sanitize(html, {});
} }

View File

@ -1,3 +0,0 @@
import DOMPurify from "isomorphic-dompurify";
export { DOMPurify };

3
src/tools/vendor/dompurify.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import DOMPurify from "dompurify";
export { DOMPurify };

View File

@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { KcSanitizer } from "keycloakify/lib/kcSanitize/KcSanitizer"; import { KcSanitizer } from "keycloakify/lib/kcSanitize/KcSanitizer";
import { decode } from "html-entities"; import { decode } from "html-entities";
import DOMPurify from "isomorphic-dompurify";
// Implementation of Keycloak Java method KeycloakSanitizerTest with bunch of more test for p tag styling // Implementation of Keycloak Java method KeycloakSanitizerTest with bunch of more test for p tag styling
// https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/test/java/org/keycloak/theme/KeycloakSanitizerTest.java#L32 // https://github.com/keycloak/keycloak/blob/8ce8a4ba089eef25a0e01f58e09890399477b9ef/services/src/test/java/org/keycloak/theme/KeycloakSanitizerTest.java#L32
@ -131,7 +132,10 @@ describe("KeycloakSanitizerMethod", () => {
}); });
function assertResult(expectedResult: string, html: string): void { function assertResult(expectedResult: string, html: string): void {
const result = KcSanitizer.sanitize(html, decode); const result = KcSanitizer.sanitize(html, {
DOMPurify: DOMPurify as any,
htmlEntitiesDecode: decode
});
expect(result).toBe(expectedResult); expect(result).toBe(expectedResult);
} }
}); });

1165
yarn.lock

File diff suppressed because it is too large Load Diff