Skip to content

Commit

Permalink
Playground block: Base64 encode attributes to prevent KSES from break…
Browse files Browse the repository at this point in the history
…ing the values on save (#258)

Some WordPress installations are overly eager with their HTML entity
encoding and will save, e.g., `<?php` as `&lt;php`. We cannot easily
detect this to decode these HTML entities only when needed, so let's
just store the attributes using base64 encoding to prevent WordPress
from breaking them.

This PR encodes the most entity-encoding-susceptible attributes to
base64 in the block editor. To avoid an expensive encode/decode
operation on each key stroke, the base64 encode operation is debounced.

 ## Testing instructions

* Install the trunk version of this block on a site
* Insert it on a page with a code editor, a few files, and a Blueprint
* Switch to this branch
* Confirm the original block still works in the editor and on the
frontend
* Update any attribute, save it, refresh the page
* Confirm it still works in the editor and on the frontend
* Insert a new block, insert a few files in the code editor, update the
Blueprint attribute, save it
* Confirm it works in the editor and on the frontend
  • Loading branch information
adamziel committed May 8, 2024
1 parent 116d5ad commit 1faf168
Show file tree
Hide file tree
Showing 4 changed files with 211 additions and 6 deletions.
4 changes: 3 additions & 1 deletion packages/playground/assets/js/playground.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
(async () => {
const onError = (error) => {
alert(`Playground couldn’t start. Please check the browser console for more information. ${error}`);
alert(
`Playground couldn’t start. Please check the browser console for more information. ${error}`
);
const backButton = document.getElementById('goBack');
if (backButton) {
backButton.click();
Expand Down
106 changes: 106 additions & 0 deletions packages/wordpress-playground-block/src/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Base64 encoding and decoding functions.
* We cannot just use `btoa` and `atob` because they do not
* support Unicode characters.
*/

export const attributesToBase64 = [
'blueprint',
'blueprintUrl',
'codeEditorErrorLog',
'constants',
'files',
];

export function base64EncodeBlockAttributes(
blockAttributes: Record<string, any>
) {
const base64Props: Record<string, string> = {};
for (const key in blockAttributes) {
if (
!attributesToBase64.includes(key) ||
typeof blockAttributes[key] === 'number' ||
typeof blockAttributes[key] === 'boolean' ||
typeof blockAttributes[key] === null ||
typeof blockAttributes[key] === undefined
) {
base64Props[key] = blockAttributes[key];
continue;
}
base64Props[key] = stringToBase64(
JSON.stringify(blockAttributes[key])
);
}
// The "files" attribute is of type array
if ('files' in base64Props) {
base64Props['files'] = [base64Props['files']] as any;
}
return base64Props;
}

/**
* Turns base64 encoded attributes back into their original form.
* It never throws, bales out early if we can't decode, and always
* returns a valid object. If any attribute cannot be decoded, it
* will be kept in its original form and presumed to have a non-base64
* value to keep the older version of the block working without
* migrating the attributes.
*
* @param base64Attributes
* @returns
*/
export function base64DecodeBlockAttributes(
base64Attributes: Record<string, any>
) {
const attributes: Record<string, any> = {};
for (const key in base64Attributes) {
let valueToDecode = base64Attributes[key];
// The "files" attribute is of type array
if (key === 'files') {
valueToDecode = valueToDecode[0];
}
if (
!attributesToBase64.includes(key) ||
!(typeof valueToDecode === 'string')
) {
attributes[key] = base64Attributes[key];
continue;
}
if (key in base64Attributes) {
try {
attributes[key] = JSON.parse(base64ToString(valueToDecode));
} catch (error) {
// Ignore errors and keep the base64 encoded string.
attributes[key] = base64Attributes[key];
}
}
}
return attributes;
}

export function stringToBase64(string: string) {
return uint8ArrayToBase64(new TextEncoder().encode(string));
}

export function base64ToString(base64: string) {
return new TextDecoder().decode(base64ToUint8Array(base64));
}

export function uint8ArrayToBase64(bytes: Uint8Array) {
const binary = [];
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary.push(String.fromCharCode(bytes[i]));
}
return window.btoa(binary.join(''));
}

export function base64ToUint8Array(base64: string) {
const binaryString = window.atob(base64); // This will convert base64 to binary string
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
100 changes: 98 additions & 2 deletions packages/wordpress-playground-block/src/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import type { Attributes } from './index';
import type { BlockEditProps } from '@wordpress/blocks';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { useState, useRef } from '@wordpress/element';
import {
ToggleControl,
SelectControl,
Expand All @@ -17,8 +18,103 @@ import {
} from '@wordpress/components';
import PlaygroundPreview from './components/playground-preview';
import './editor.scss';
import {
attributesToBase64,
base64DecodeBlockAttributes,
base64EncodeBlockAttributes,
} from './base64';

/**
* Some WordPress installations are overly eager with their HTML entity encoding
* and will save `<?php` as `&lt;php`. We cannot easily detect this to decode
* these HTML entities only when needed, so let's just store the attributes using
* base64 encoding to prevent WordPress from breaking them.
*/
function withBase64Attrs(Component: any) {
return (props: any) => {
const ref = useRef<any>({
encodeTimeout: null,
});
// Store the base64 encoded attributes are stored in a local state in a
// decoded form to avoid encoding/decoding on each keystroke.
const [base64Attributes, setBase64Attributes] = useState<
Record<string, any>
>(() => {
const attrs: Record<string, any> = {};
for (const key in props.attributes) {
if (attributesToBase64.includes(key)) {
attrs[key] = props.attributes[key];
}
}
return base64DecodeBlockAttributes(attrs);
});
// Pass the non-base64 attributes to the component as they are on each
// render.
const nonBase64Attributes: Record<string, any> = {};
for (const key in props.attributes) {
if (!attributesToBase64.includes(key)) {
nonBase64Attributes[key] = props.attributes[key];
}
}

/**
* Store the base64 encoded attributes in the local state instead of
* calling setAttributes() on each change. Then, debounce the actual
* setAttributes() call to prevent encoding/decoding/re-render on each
* key stroke.
*
* Other attributes are just passed to props.setAttributes().
*/
function setAttributes(attributes: any) {
const deltaBase64Attributes: Record<string, string> = {};
const deltaRest: Record<string, string> = {};
for (const key in attributes) {
if (attributesToBase64.includes(key)) {
deltaBase64Attributes[key] = attributes[key];
} else {
deltaRest[key] = attributes[key];
}
}
if (Object.keys(deltaRest).length > 0) {
props.setAttributes(deltaRest);
}

const newBase64Attributes: Record<string, any> = {
...base64Attributes,
...deltaBase64Attributes,
};
if (Object.keys(deltaBase64Attributes).length > 0) {
setBase64Attributes(newBase64Attributes);
}

// Debounce the encoding to prevent encoding/decoding/re-render on
// each key stroke.
if (ref.current.encodeTimeout) {
clearTimeout(ref.current.encodeTimeout);
}
ref.current.encodeTimeout = setTimeout(() => {
props.setAttributes(
base64EncodeBlockAttributes(newBase64Attributes)
);
clearTimeout(ref.current.encodeTimeout);
ref.current.encodeTimeout = null;
}, 100);
}

export default function Edit({
return (
<Component
{...props}
setAttributes={setAttributes}
attributes={{
...nonBase64Attributes,
...base64Attributes,
}}
/>
);
};
}

export default withBase64Attrs(function Edit({
isSelected,
setAttributes,
attributes,
Expand Down Expand Up @@ -433,4 +529,4 @@ export default function Edit({
</InspectorControls>
</div>
);
}
});
7 changes: 4 additions & 3 deletions packages/wordpress-playground-block/src/view.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { createRoot } from '@wordpress/element';
import PlaygroundPreview from './components/playground-preview';
import { base64DecodeBlockAttributes } from './base64';

function renderPlaygroundPreview() {
const playgroundDemo = Array.from(
Expand All @@ -10,9 +11,9 @@ function renderPlaygroundPreview() {
for (const element of playgroundDemo) {
const rootElement = element as HTMLDivElement;
const root = createRoot(rootElement);
const attributes = JSON.parse(
atob(rootElement.dataset['attributes'] || '')
);
const attributes = base64DecodeBlockAttributes(
JSON.parse(atob(rootElement.dataset['attributes'] || ''))
) as any;

root.render(<PlaygroundPreview {...attributes} />);
}
Expand Down

0 comments on commit 1faf168

Please sign in to comment.