lit-html recognized template strings and XSS mitigations
This commit is contained in:
Родитель
ce6ff8a491
Коммит
02cb63cf1b
|
@ -3,6 +3,7 @@
|
|||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
"eg2.eslint"
|
||||
"eg2.eslint",
|
||||
"bierner.lit-html"
|
||||
]
|
||||
}
|
106
src/formatter.ts
106
src/formatter.ts
|
@ -16,18 +16,18 @@ export const MAX_FILE_NAME_LENGTH: number = 60;
|
|||
* @param decimalPlaces The number of decimal places to show.
|
||||
*/
|
||||
export const duration = (end: number, start: number, decimalPlaces: number = DECIMAL_PLACES): string => {
|
||||
if (isNaN(end) ||
|
||||
isNaN(start) ||
|
||||
(end - start < 0) ||
|
||||
(end === 0 && start === 0)) {
|
||||
return "-";
|
||||
}
|
||||
if (isNaN(end) ||
|
||||
isNaN(start) ||
|
||||
(end - start < 0) ||
|
||||
(end === 0 && start === 0)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return (end - start).toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
});
|
||||
};
|
||||
return (end - start).toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a path into a filename.
|
||||
|
@ -35,14 +35,14 @@ export const duration = (end: number, start: number, decimalPlaces: number = DEC
|
|||
* @param maxLength The maximum length of the returned file name, plus three characters for periods.
|
||||
*/
|
||||
export const pathToFilename = (path: string, maxLength: number = MAX_FILE_NAME_LENGTH): string => {
|
||||
let trimmed: string = path.substring(path.lastIndexOf("/") + 1);
|
||||
let trimmed: string = path.substring(path.lastIndexOf("/") + 1);
|
||||
|
||||
if (trimmed.length > maxLength) {
|
||||
trimmed = `${trimmed.substring(0, maxLength)}...`;
|
||||
}
|
||||
if (trimmed.length > maxLength) {
|
||||
trimmed = `${trimmed.substring(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
enum FileSizeUnits {
|
||||
b,
|
||||
|
@ -61,23 +61,65 @@ const LOCALE_STRING_DECIMAL_PLACES: { maximumFractionDigits: number; minimumFrac
|
|||
/**
|
||||
* Converts a size in bytes to another size, with a unit.
|
||||
* @param bytes The size in bytes.
|
||||
* @param unit The desired unit to conver to.
|
||||
* @param unit The desired unit to convert to.
|
||||
*/
|
||||
export const sizeToString = (bytes: number, unit: keyof typeof FileSizeUnits = "Kb"): string => {
|
||||
const twoExpTen: number = 1024;
|
||||
const twoExpTen: number = 1024;
|
||||
|
||||
if (bytes === 0) {
|
||||
return "-";
|
||||
if (bytes === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case FileSizeUnits[FileSizeUnits.b]:
|
||||
return `${bytes.toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} b`;
|
||||
case FileSizeUnits[FileSizeUnits.Kb]:
|
||||
return `${(bytes / twoExpTen).toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} Kb`;
|
||||
case FileSizeUnits[FileSizeUnits.Mb]:
|
||||
return `${(bytes / (twoExpTen * twoExpTen)).toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} Mb`;
|
||||
default:
|
||||
throw new Error("unknown unit");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes a formatter for HTML template strings.
|
||||
* Naming it method .html(...) allows lit-html tooling to recognize the template string as HTML.
|
||||
*/
|
||||
type HtmlTemplateStringFormatter = { html(literals: TemplateStringsArray, ...placeholders: string[]): string };
|
||||
|
||||
/**
|
||||
* Sanitizes inputs into an HTML template string.
|
||||
*/
|
||||
export const sanitize: HtmlTemplateStringFormatter = {
|
||||
html: (literals: TemplateStringsArray, ...placeholders: string[]): string => {
|
||||
for (let i = 0; i < placeholders.length; i++) {
|
||||
placeholders[i] = placeholders[i].replace(
|
||||
/["&<>'\\`=]/g,
|
||||
(str: string) => `&#${str.charCodeAt(0)};`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case FileSizeUnits[FileSizeUnits.b]:
|
||||
return `${bytes.toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} b`;
|
||||
case FileSizeUnits[FileSizeUnits.Kb]:
|
||||
return `${(bytes / twoExpTen).toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} Kb`;
|
||||
case FileSizeUnits[FileSizeUnits.Mb]:
|
||||
return `${(bytes / (twoExpTen * twoExpTen)).toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} Mb`;
|
||||
default:
|
||||
throw new Error("unknown unit");
|
||||
}
|
||||
};
|
||||
return html(literals, ...placeholders);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an HTML template string for output.
|
||||
* We provide this method so lit-html tooling can recognize HTML strings.
|
||||
*/
|
||||
export const html = (literals: TemplateStringsArray, ...placeholders: string[]): string => {
|
||||
const outArr: string[] = new Array(literals.length + placeholders.length);
|
||||
const endsWithPlaceholder = literals.length === placeholders.length;
|
||||
|
||||
for (let inIndex = 0, outIndex = 0; outIndex < outArr.length - 1; inIndex++) {
|
||||
outArr[outIndex++] = literals[inIndex];
|
||||
outArr[outIndex++] = placeholders[inIndex];
|
||||
}
|
||||
|
||||
if (!endsWithPlaceholder) {
|
||||
outArr[outArr.length - 1] = literals[literals.length - 1];
|
||||
}
|
||||
|
||||
return outArr.join("");
|
||||
};
|
||||
|
|
|
@ -22,7 +22,7 @@ export interface INavigationTimingsPanelConfig extends IPanelConfig {
|
|||
}
|
||||
|
||||
/** A set of default configuration options for the navigation timings panel */
|
||||
const navigationTimingsPanelDefaultConfig: INavigationTimingsPanelConfig = {
|
||||
const navigationTimingsPanelDefaultConfig: Required<INavigationTimingsPanelConfig> = {
|
||||
goalMs: 500,
|
||||
timings: performance.timing,
|
||||
panelName: "Navigation Timings",
|
||||
|
@ -36,7 +36,7 @@ const navigationTimingsPanelDefaultConfig: INavigationTimingsPanelConfig = {
|
|||
export class NavigationTimingsPanel implements IPanel {
|
||||
|
||||
/** The settings for this panel. */
|
||||
private readonly config: INavigationTimingsPanelConfig;
|
||||
private readonly config: Required<INavigationTimingsPanelConfig>;
|
||||
|
||||
/** The frame that displays this panel. */
|
||||
private readonly frame: PanelFrame;
|
||||
|
@ -67,7 +67,7 @@ export class NavigationTimingsPanel implements IPanel {
|
|||
public render(target: HTMLElement): void {
|
||||
const t: PerformanceTiming = this.config.timings;
|
||||
|
||||
target.innerHTML = `<h1>${this.config.panelName}</h1>
|
||||
target.innerHTML = Formatter.html`<h1>${this.config.panelName}</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Get Connected</th>
|
||||
|
|
|
@ -137,7 +137,7 @@ export class ResourceTimingsPanel implements IPanel {
|
|||
*/
|
||||
private getDetailTable(): string {
|
||||
const entries: IResourcePerformanceEntry[] = this.config.performance.getEntriesByType("resource") as IResourcePerformanceEntry[];
|
||||
const rows: string = entries.map((entry: IResourcePerformanceEntry) => `
|
||||
const rows: string = entries.map((entry: IResourcePerformanceEntry) => Formatter.html`
|
||||
<tr>
|
||||
<td>${entry.initiatorType}</td>
|
||||
<td title="${entry.name}">${Formatter.pathToFilename(entry.name)}</td>
|
||||
|
@ -167,12 +167,12 @@ export class ResourceTimingsPanel implements IPanel {
|
|||
* @param summaryCounts An array of summaries data.
|
||||
*/
|
||||
private getSummaryTable(summaryCounts: SummaryRow[]): string {
|
||||
const rows: string = summaryCounts.map((row: SummaryRow): string => `
|
||||
const rows: string = summaryCounts.map((row: SummaryRow): string => Formatter.html`
|
||||
<tr>
|
||||
<td>${row.format}</td>
|
||||
<td class="numeric">${Formatter.sizeToString(row.decodedBytes)}</td>
|
||||
<td class="numeric">${Formatter.sizeToString(row.overWireBytes)}</td>
|
||||
<td class="numeric">${row.numFiles}</td>
|
||||
<td class="numeric">${row.numFiles.toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
|
||||
<td class="numeric">${Formatter.sizeToString(row.largestBytes)}</td>
|
||||
</tr>`).join("");
|
||||
|
||||
|
|
|
@ -64,4 +64,61 @@ describe("Formatter", () => {
|
|||
expect(Formatter.sizeToString(twoToTheTenth * twoToTheTenth, "Mb")).to.equal("1.00 Mb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("html", () => {
|
||||
it("should pass through multiple values strings", () => {
|
||||
const href = "https://github.com";
|
||||
const name = "GitHub";
|
||||
expect(Formatter.html`<a href="${href}">${name}</a>`).to.equal('<a href="https://github.com">GitHub</a>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitize.html", () => {
|
||||
it("should pass through a literal", () => {
|
||||
expect(Formatter.sanitize.html`a`).to.equal("a");
|
||||
});
|
||||
|
||||
it("should sanitize only a placeholder", () => {
|
||||
expect(Formatter.sanitize.html`${"&"}`).to.equal("&");
|
||||
});
|
||||
|
||||
it("should sanitize one literal and one placeholder", () => {
|
||||
expect(Formatter.sanitize.html`a${"&"}`).to.equal("a&");
|
||||
});
|
||||
|
||||
it("should be tested on realistic input", () => {
|
||||
const unsafe = "<script>alert(window);</script>";
|
||||
expect(Formatter.sanitize.html`<p>${unsafe}</p>`).to.equal("<p><script>alert(window);</script></p>");
|
||||
});
|
||||
|
||||
describe("exhaustive unsafe characters", () => {
|
||||
it("should sanitize quotations", () => {
|
||||
expect(Formatter.sanitize.html`quot ${"\""}`).to.equal("quot "");
|
||||
});
|
||||
|
||||
it("should sanitize ampersands", () => {
|
||||
expect(Formatter.sanitize.html`amp ${"&"}`).to.equal("amp &");
|
||||
});
|
||||
|
||||
it("should sanitize less than", () => {
|
||||
expect(Formatter.sanitize.html`lt ${"<"}`).to.equal("lt <");
|
||||
});
|
||||
|
||||
it("should sanitize greater than", () => {
|
||||
expect(Formatter.sanitize.html`gt ${">"}`).to.equal("gt >");
|
||||
});
|
||||
|
||||
it("should sanitize single quotes", () => {
|
||||
expect(Formatter.sanitize.html`apos ${"'"}`).to.equal("apos '");
|
||||
});
|
||||
|
||||
it("should sanitize back slash", () => {
|
||||
expect(Formatter.sanitize.html`back slash ${"\\"}`).to.equal("back slash \");
|
||||
});
|
||||
|
||||
it("should sanitize equals", () => {
|
||||
expect(Formatter.sanitize.html`equal ${"="}`).to.equal("equal =");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,7 +35,8 @@
|
|||
"Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License."
|
||||
],
|
||||
"object-literal-sort-keys": false,
|
||||
"newline-per-chained-call": false
|
||||
"newline-per-chained-call": false,
|
||||
"one-variable-per-declaration": [true, "ignore-for-loop"]
|
||||
},
|
||||
"jsRules": {
|
||||
"max-line-length": {
|
||||
|
|
Загрузка…
Ссылка в новой задаче