Skip to content

Commit

Permalink
Adds PrerenderResource.fromBinary().
Browse files Browse the repository at this point in the history
Refs #71.

This limits `PrerenderResource.of()` to just HTML content, simplifying the implementation and being more clear about the security impact of using different functions. Like `fromText()`, `fromBinary()` also rejects outputting to a `*.html` or `*.htm` file, as `PrerenderResource.of()` should be used instead.
  • Loading branch information
dgp1130 committed Mar 13, 2023
1 parent 0aab086 commit 217ae5b
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 51 deletions.
76 changes: 38 additions & 38 deletions common/models/prerender_resource.mts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ export class PrerenderResource {
*
* @param path The path the file will be generated at relative to the final
* generated site. Must begin with a `/` character.
* @param contents A {@link SafeHtml}, {@link ArrayBuffer}, or
* {@link TypedArray} object with the file contents of the resource. If
* {@link SafeHtml} is given, it is encoded as a UTF-8 string. If an
* {@link ArrayBuffer} or {@link TypedArray} is given, it used as is.
* @returns A `PrerenderResource` object representing the resource.
* @param contents A {@link SafeHtml} object to encode as a UTF-8 string.
* @returns A {@link PrerenderResource} object representing the resource.
*/
public static of(
path: string,
contents: SafeHtml | ArrayBuffer | TypedArray,
): PrerenderResource {
public static of(path: string, contents: SafeHtml): PrerenderResource {
if (!isSafeHtml(contents)) {
throw new Error(`Only \`SafeHtml\` objects can be used in \`*.html\` or \`*.htm\` files. Use a rendering engine like \`@rules_prerender/preact\` to render to \`SafeHtml\`.`);
}

const binary =
new TextEncoder().encode(contents.getHtmlAsString()).buffer;
return new PrerenderResource({
urlPath: UrlPath.of(path),
contents: normalizeContents(contents),
contents: binary,
});
}

Expand All @@ -52,7 +52,7 @@ export class PrerenderResource {
* `.html` or `.htm`. Use {@link PrerenderResource.of} with
* {@link SafeHtml} to generate HTML content.
* @param contents A UTF-8 encoded string to output at the given path.
* @returns A `PrerenderResource` object representing the resource.
* @returns A {@link PrerenderResource} object representing the resource.
*/
public static fromText(path: string, contents: string): PrerenderResource {
// Reject outputting `*.html` and `*.htm` files from plain text. There
Expand All @@ -68,35 +68,35 @@ export class PrerenderResource {
contents: new TextEncoder().encode(contents).buffer,
});
}
}

/**
* Accepts various input types and normalizes them to a simple
* {@link ArrayBuffer} representing the input content. If the input is a
* {@link SafeHtml}, it will be encoded as a UTF-8 string. If the input is an
* {@link ArrayBuffer} or a {@link TypedArray}, its content is used as is.
*
* NOTE: {@link TypedArray} does **not** extend {@link ArrayBuffer}, however
* they are unfortunately compatible from a structural typing perspective, so
* TypeScript allows a {@link Uint8Array} in place of an {@link ArrayBuffer},
* even though something like `instanceof ArrayBuffer` would return `false`.
* This is an easy foot-gun for users to encounter, so we should support such
* inputs as a result.
*/
function normalizeContents(contents: SafeHtml | ArrayBuffer | TypedArray):
ArrayBuffer {
if (contents instanceof ArrayBuffer) return contents;
if (isTypedArray(contents)) return contents.buffer;
if (isSafeHtml(contents)) {
return new TextEncoder().encode(contents.getHtmlAsString()).buffer;
}
/**
* Returns a {@link PrerenderResource} representing a file with the provided
* {@param contents} at the given {@param path} within the site.
*
* @param path The path the file will be generated at relative to the final
* generated site. Must begin with a `/` character. Must *not* end in
* `.html` or `.htm`. Use {@link PrerenderResource.of} with
* {@link SafeHtml} to generate HTML content.
* @param contents Binary content to associate with the given path.
* @returns A {@link PrerenderResource} object representing the resource.
*/
public static fromBinary(
path: string,
contents: ArrayBuffer | TypedArray,
): PrerenderResource {
// Reject outputting `*.html` and `*.htm` files from plain text. There
// is no general expectation that the input raw string was safely
// constructed and there could be injection attacks within it.
if (path.endsWith('.html') || path.endsWith('.htm')) {
throw new Error(`Cannot generate a \`*.html\` or \`*.htm\` file (${
path}) from a raw string (this would be unsafe!). HTML content should be rendered to \`SafeHtml\` first, and then written to a file in \`PrerenderResource.of()\`.`);
}

// Should never happen if TypeScript types are respected, but JavaScript
// users or unsound input types may hit this case.
throw new Error(
`Input is not a \`SafeHtml\`, \`ArrayBuffer\`, or \`TypedArray\`:\n${
contents}`,
);
return new PrerenderResource({
urlPath: UrlPath.of(path),
contents: isTypedArray(contents) ? contents.buffer : contents,
});
}
}

/**
Expand Down
62 changes: 50 additions & 12 deletions common/models/prerender_resource_test.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { safe } from '../safe_html/safe_html.mjs';
import { safe, SafeHtml } from '../safe_html/safe_html.mjs';
import { PrerenderResource } from './prerender_resource.mjs';

describe('PrerenderResource', () => {
Expand All @@ -11,18 +11,16 @@ describe('PrerenderResource', () => {
expect(new TextDecoder().decode(res.contents)).toBe('<div></div>');
});

it('returns a `PrerenderResource` from binary data', () => {
const res = PrerenderResource.of(
'/foo/bar.html', new Uint8Array([ 0, 1, 2, 3 ]));

expect(res.path).toBe('/foo/bar.html');
it('throws when given non-`SafeHtml` input', () => {
expect(() => PrerenderResource.of(
'/foo/bar.html',
'unsafe HTML content' as unknown as SafeHtml,
)).toThrowError(/Only `SafeHtml` objects can be used in `\*.html` or `\*.htm` files\./);

const contents = new Uint8Array(res.contents);
expect(contents.length).toBe(4);
expect(contents[0]).toBe(0);
expect(contents[1]).toBe(1);
expect(contents[2]).toBe(2);
expect(contents[3]).toBe(3);
expect(() => PrerenderResource.of(
'/foo/bar.html',
{ getHtmlAsString: () => 'unsafe HTML content' } as SafeHtml,
)).toThrowError(/Only `SafeHtml` objects can be used in `\*.html` or `\*.htm` files\./);
});

it('throws when given an invalid URL path', () => {
Expand Down Expand Up @@ -71,4 +69,44 @@ describe('PrerenderResource', () => {
)).toThrowError(/this would be unsafe/);
});
});

describe('fromBinary()', () => {
it('returns a `PrerenderResource` from a `TypedArray`', () => {
const res = PrerenderResource.fromBinary(
'/foo/bar.bin', new Uint8Array([ 0, 1, 2, 3 ]));

expect(res.path).toBe('/foo/bar.bin');

const contents = new Uint8Array(res.contents);
expect(contents.length).toBe(4);
expect(contents[0]).toBe(0);
expect(contents[1]).toBe(1);
expect(contents[2]).toBe(2);
expect(contents[3]).toBe(3);
});

it('returns a `PrerenderResource` from an `ArrayBuffer`', () => {
const res = PrerenderResource.fromBinary(
'/foo/bar.bin', new Uint8Array([ 0, 1, 2, 3 ]).buffer);

expect(res.path).toBe('/foo/bar.bin');

const contents = new Uint8Array(res.contents);
expect(contents.length).toBe(4);
expect(contents[0]).toBe(0);
expect(contents[1]).toBe(1);
expect(contents[2]).toBe(2);
expect(contents[3]).toBe(3);
});

it('throws an error when used with an HTML path', () => {
expect(() => PrerenderResource.fromBinary(
'/index.html', new Uint8Array([ 0, 1, 2, 3 ]),
)).toThrowError(/this would be unsafe/);

expect(() => PrerenderResource.fromBinary(
'/index.htm', new Uint8Array([ 0, 1, 2, 3 ]),
)).toThrowError(/this would be unsafe/);
});
});
});
3 changes: 2 additions & 1 deletion examples/prerender_resources/generator.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export default function* (): Generator<PrerenderResource, void, void> {
yield PrerenderResource.fromText('/goodbye.txt', 'Goodbye, World!');

// Can prerender binary files.
yield PrerenderResource.of('/data.bin', new Uint8Array([ 0, 1, 2, 3 ]));
yield PrerenderResource.fromBinary(
'/data.bin', new Uint8Array([ 0, 1, 2, 3 ]));
}

0 comments on commit 217ae5b

Please sign in to comment.