Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#7265: Move Nunjucks template validation to sandbox #7426

Merged
merged 12 commits into from
Jan 26, 2024
22 changes: 11 additions & 11 deletions src/analysis/analysisVisitors/requestPermissionAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,12 @@ class RequestPermissionAnalysis extends AnalysisVisitorABC {

const permissionsValue = `${parsedURL.origin}/*`;

const permissionCheckPromise = browser.permissions
.contains({
origins: [parsedURL.href],
})
// eslint-disable-next-line promise/prefer-await-to-then -- need the complete Promise
.then((hasPermission) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, before:

// eslint-disable-next-line promise/prefer-await-to-then -- need the complete Promise
const promise = x().then(y => {
  z(y);
});

After:

const promise = (async () => {
  const y = await x();
  z(y);
})();

While a bit more verbose, it's easier to deal with linear await code than then/catch pipelines, which is the reason behind promise/prefer-await-to-then. Once the eslint-disable-next-line comment is factored in, it's actually shorter.

Another example

// Decorate the extension promise with tour tracking
const runPromise = promise
// eslint-disable-next-line promise/prefer-await-to-then -- avoid separate method
.then(() => {
markTourEnd(nonce, { context });
})
// eslint-disable-next-line promise/prefer-await-to-then -- avoid separate method
.catch((error) => {
markTourEnd(nonce, { error, context });
});

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I wonder what the thought process was behind using the promise callback in the first place.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know a good reason off the top of my head. In some places we used to use it because microtasks threw off browser user gesture recognition, but that wouldn't apply here

if (!hasPermission) {
this.permissionCheckPromises.push(
(async () => {
const hasPermissions = await browser.permissions.contains({
origins: [parsedURL.href],
});
if (!hasPermissions) {
this.annotations.push({
position: nestedPosition(position, "config.url"),
message:
Expand All @@ -134,17 +133,18 @@ class RequestPermissionAnalysis extends AnalysisVisitorABC {
],
});
}
});

this.permissionCheckPromises.push(permissionCheckPromise);
})(),
);
}
}

override async run(extension: ModComponentFormState): Promise<void> {
super.run(extension);

// Use allSettled because `browser.permissions.contains` errors out for certain cases, e.g., malformed URLs
await allSettled(this.permissionCheckPromises, { catch: "ignore" });
await allSettled(this.permissionCheckPromises.splice(0), {
catch: "ignore",
});
fregante marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down
42 changes: 26 additions & 16 deletions src/analysis/analysisVisitors/templateAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
} from "@/analysis/analysisTypes";
import { type BrickPosition } from "@/bricks/types";
import { isMustacheOnly } from "@/components/fields/fieldUtils";
import { Template } from "nunjucks";
import PipelineExpressionVisitor from "@/bricks/PipelineExpressionVisitor";
import { type ModComponentFormState } from "@/pageEditor/starterBricks/formStateTypes";
import { type Expression } from "@/types/runtimeTypes";
Expand All @@ -30,6 +29,8 @@ import {
isNunjucksExpression,
isTemplateExpression,
} from "@/utils/expressionUtils";
import { validateNunjucksTemplate } from "@/sandbox/messenger/api";
import { getErrorMessage } from "@/errors/errorHelpers";

const TEMPLATE_ERROR_MESSAGE =
"Invalid text template. Read more about text templates: https://docs.pixiebrix.com/developing-mods/developer-concepts/text-template-guide";
Expand All @@ -41,6 +42,10 @@ type PushAnnotationArgs = {
};

class TemplateAnalysis extends PipelineExpressionVisitor implements Analysis {
// XXX: for now we handle asynchronous pipeline traversal by gathering all the promises and awaiting them all
// see discussion https://github.com/pixiebrix/pixiebrix-extension/pull/4013#discussion_r944690969
private readonly nunjuckValidationPromises: Array<Promise<void>> = [];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment copy-pasted from existing code


get id() {
return "template";
}
Expand All @@ -50,10 +55,12 @@ class TemplateAnalysis extends PipelineExpressionVisitor implements Analysis {
return this.annotations;
}

run(extension: ModComponentFormState): void {
async run(extension: ModComponentFormState): Promise<void> {
this.visitRootPipeline(extension.extension.blockPipeline, {
extensionPointType: extension.type,
});

await Promise.all(this.nunjuckValidationPromises.splice(0));
fregante marked this conversation as resolved.
Show resolved Hide resolved
}

private pushErrorAnnotation({
Expand All @@ -74,7 +81,10 @@ class TemplateAnalysis extends PipelineExpressionVisitor implements Analysis {
position: BrickPosition,
expression: Expression<unknown>,
): void {
if (!isTemplateExpression(expression)) {
if (
!isTemplateExpression(expression) ||
expression.__value__.trim() === ""
) {
return;
}

Expand All @@ -90,19 +100,19 @@ class TemplateAnalysis extends PipelineExpressionVisitor implements Analysis {
expression,
});
} else if (isNunjucksExpression(expression)) {
try {
// eslint-disable-next-line no-new
new Template(expression.__value__, undefined, undefined, true);
} catch (error) {
// @ts-expect-error nunjucks error does have message property
const failureCause = (error.message as string)
?.replace("(unknown path)", "")
.trim();

const message = `Invalid template: ${failureCause}.`;

this.pushErrorAnnotation({ position, message, expression });
}
this.nunjuckValidationPromises.push(
(async () => {
try {
await validateNunjucksTemplate(expression.__value__);
} catch (error) {
this.pushErrorAnnotation({
position,
message: getErrorMessage(error),
expression,
});
}
})(),
);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/contentScript/sidebarController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export function removeExtensions(extensionIds: UUID[]): void {
console.debug("sidebarController:removeExtensions", { extensionIds });

// `panels` is const, so replace the contents
const current = panels.splice(0, panels.length);
const current = panels.splice(0);
Copy link
Contributor Author

@fregante fregante Jan 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This is the default

I'm not a fan of splice in general so I'd be open to adding a cloneAndClearArray utility to make the intent clear.

Suggested change
const current = panels.splice(0);
const current = cloneAndClearArray(panels);

panels.push(...current.filter((x) => !extensionIds.includes(x.extensionId)));
void renderPanelsIfVisible();
}
Expand All @@ -286,7 +286,7 @@ export function removeExtensionPoint(
});

// `panels` is const, so replace the contents
const current = panels.splice(0, panels.length);
const current = panels.splice(0);
panels.push(
...current.filter(
(x) =>
Expand Down
14 changes: 14 additions & 0 deletions src/sandbox/messenger/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type TemplateRenderPayload = {
autoescape: boolean;
};

export type TemplateValidatePayload = string;

export async function renderNunjucksTemplate(payload: TemplateRenderPayload) {
return (await isSandboxed())
? postMessage({
Expand All @@ -61,6 +63,18 @@ export async function renderNunjucksTemplate(payload: TemplateRenderPayload) {
: directApi.renderNunjucksTemplate(payload);
}

export async function validateNunjucksTemplate(
payload: TemplateValidatePayload,
) {
return (await isSandboxed())
? postMessage({
recipient: await loadSandbox(),
payload,
type: "VALIDATE_NUNJUCKS",
})
: directApi.validateNunjucksTemplate(payload);
}

export async function renderHandlebarsTemplate(payload: TemplateRenderPayload) {
return (await isSandboxed())
? postMessage({
Expand Down
29 changes: 28 additions & 1 deletion src/sandbox/messenger/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { type JavaScriptPayload, type TemplateRenderPayload } from "./api";
import {
type TemplateValidatePayload,
type JavaScriptPayload,
type TemplateRenderPayload,
} from "./api";
import { isErrorObject } from "@/errors/errorHelpers";
import {
BusinessError,
Expand Down Expand Up @@ -45,6 +49,29 @@ export async function renderNunjucksTemplate(
}
}

export async function validateNunjucksTemplate(
template: TemplateValidatePayload,
): Promise<void> {
console.log("validateNunjucksTemplate", template);

// Webpack caches the module import, so doesn't need to cache via lodash's `once`
const { Template } = await import(
/* webpackChunkName: "nunjucks" */ "nunjucks"
);

try {
// eslint-disable-next-line no-new
new Template(template, undefined, undefined, true);
} catch (error) {
if (isErrorObject(error) && error.name === "Template render error") {
const failureCause = error.message?.replace("(unknown path)", "").trim();
throw new InvalidTemplateError(failureCause, template);
}

throw error;
}
}

export async function renderHandlebarsTemplate(
payload: TemplateRenderPayload,
): Promise<string> {
Expand Down
2 changes: 2 additions & 0 deletions src/sandbox/messenger/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ import { addPostMessageListener } from "@/utils/postMessage";
import {
renderHandlebarsTemplate,
renderNunjucksTemplate,
validateNunjucksTemplate,
runUserJs,
} from "./executor";

export default function registerMessenger(): void {
addPostMessageListener("RENDER_NUNJUCKS", renderNunjucksTemplate);
addPostMessageListener("VALIDATE_NUNJUCKS", validateNunjucksTemplate);
addPostMessageListener("RENDER_HANDLEBARS", renderHandlebarsTemplate);
addPostMessageListener("RUN_USER_JS", runUserJs);
addPostMessageListener("SANDBOX_PING", async (payload) => "pong");
Expand Down
2 changes: 1 addition & 1 deletion src/starterBricks/contextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export abstract class ContextMenuStarterBrickABC extends StarterBrickABC<Context

override uninstall({ global = false }: { global?: boolean }): void {
// NOTE: don't uninstall the mouse/click handler because other context menus need it
const extensions = this.modComponents.splice(0, this.modComponents.length);
const extensions = this.modComponents.splice(0);
if (global) {
for (const extension of extensions) {
void uninstallContextMenu({ extensionId: extension.id });
Expand Down
2 changes: 1 addition & 1 deletion src/starterBricks/menuItemExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ export abstract class MenuItemStarterBrickABC extends StarterBrickABC<MenuItemSt
const menus = [...this.menus.values()];

// Clear so they don't get re-added by the onNodeRemoved mechanism
const extensions = this.modComponents.splice(0, this.modComponents.length);
const extensions = this.modComponents.splice(0);
this.menus.clear();

if (extensions.length === 0) {
Expand Down
2 changes: 1 addition & 1 deletion src/starterBricks/sidebarExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export abstract class SidebarStarterBrickABC extends StarterBrickABC<SidebarConf
}

public override uninstall(): void {
const extensions = this.modComponents.splice(0, this.modComponents.length);
const extensions = this.modComponents.splice(0);
this.clearModComponentInterfaceAndEvents(extensions.map((x) => x.id));
removeExtensionPoint(this.id);
console.debug(
Expand Down
4 changes: 2 additions & 2 deletions src/utils/postMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { type JsonValue } from "type-fest";

const TIMEOUT_MS = 3000;

type Payload = JsonValue;
type Payload = JsonValue | void;

// eslint-disable-next-line local-rules/persistBackgroundData -- Function
const log = process.env.SANDBOX_LOGGING ? console.debug : () => {};
Expand All @@ -56,7 +56,7 @@ export interface PostMessageInfo {
recipient: Window;
}

type PostMessageListener = (payload?: Payload) => Promise<Payload>;
type PostMessageListener = (payload?: Payload) => Promise<Payload | void>;

/** Use the postMessage API but expect a response from the target */
export default async function postMessage({
Expand Down
Loading