lit-html recognized template strings and XSS mitigations

This commit is contained in:
Adam Reineke 2018-01-29 16:18:21 -08:00
Родитель ce6ff8a491
Коммит 02cb63cf1b
6 изменённых файлов: 141 добавлений и 40 удалений

3
.vscode/extensions.json поставляемый
Просмотреть файл

@ -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"
]
}

Просмотреть файл

@ -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("&#38;");
});
it("should sanitize one literal and one placeholder", () => {
expect(Formatter.sanitize.html`a${"&"}`).to.equal("a&#38;");
});
it("should be tested on realistic input", () => {
const unsafe = "<script>alert(window);</script>";
expect(Formatter.sanitize.html`<p>${unsafe}</p>`).to.equal("<p>&#60;script&#62;alert(window);&#60;/script&#62;</p>");
});
describe("exhaustive unsafe characters", () => {
it("should sanitize quotations", () => {
expect(Formatter.sanitize.html`quot ${"\""}`).to.equal("quot &#34;");
});
it("should sanitize ampersands", () => {
expect(Formatter.sanitize.html`amp ${"&"}`).to.equal("amp &#38;");
});
it("should sanitize less than", () => {
expect(Formatter.sanitize.html`lt ${"<"}`).to.equal("lt &#60;");
});
it("should sanitize greater than", () => {
expect(Formatter.sanitize.html`gt ${">"}`).to.equal("gt &#62;");
});
it("should sanitize single quotes", () => {
expect(Formatter.sanitize.html`apos ${"'"}`).to.equal("apos &#39;");
});
it("should sanitize back slash", () => {
expect(Formatter.sanitize.html`back slash ${"\\"}`).to.equal("back slash &#92;");
});
it("should sanitize equals", () => {
expect(Formatter.sanitize.html`equal ${"="}`).to.equal("equal &#61;");
});
});
});
});

Просмотреть файл

@ -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": {