diff --git a/.eslintrc.production.yml b/.eslintrc.production.yml index 807466152a..7ef247d2d9 100644 --- a/.eslintrc.production.yml +++ b/.eslintrc.production.yml @@ -39,3 +39,8 @@ rules: message: Use clock functions from ponyfill - name: setTimeout message: Use clock functions from ponyfill + no-restricted-properties: + - error + - object: URL + property: createObjectURL + message: Make sure content type is sanitized before presenting the URL in HTML diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e795496f..4bbf64263e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - `@msinternal/botframework-webchat-debug-theme` package for enabling debugging scenarios - `@msinternal/botframework-webchat-react-hooks` for helpers for React hooks - Added link sanitization and ESLint rules, in PR [#5564](https://github.com/microsoft/BotFramework-WebChat/pull/5564), by [@compulim](https://github.com/compulim) +- Added blob URL sanitization and ESLint rules, in PR [#5568](https://github.com/microsoft/BotFramework-WebChat/pull/5568), by [@compulim](https://github.com/compulim) ### Changed diff --git a/__tests__/html2/attachment/contentType/binary.html b/__tests__/html2/attachment/contentType/binary.html new file mode 100644 index 0000000000..065fe0b9a3 --- /dev/null +++ b/__tests__/html2/attachment/contentType/binary.html @@ -0,0 +1,53 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html2/attachment/contentType/binary.html.snap-1.png b/__tests__/html2/attachment/contentType/binary.html.snap-1.png new file mode 100644 index 0000000000..e8de45c8f4 Binary files /dev/null and b/__tests__/html2/attachment/contentType/binary.html.snap-1.png differ diff --git a/__tests__/html2/attachment/contentType/png.html b/__tests__/html2/attachment/contentType/png.html new file mode 100644 index 0000000000..9f6f26eee2 --- /dev/null +++ b/__tests__/html2/attachment/contentType/png.html @@ -0,0 +1,53 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html2/attachment/contentType/png.html.snap-1.png b/__tests__/html2/attachment/contentType/png.html.snap-1.png new file mode 100644 index 0000000000..e8de45c8f4 Binary files /dev/null and b/__tests__/html2/attachment/contentType/png.html.snap-1.png differ diff --git a/__tests__/html2/attachment/contentType/text.html b/__tests__/html2/attachment/contentType/text.html new file mode 100644 index 0000000000..ce33e26e0d --- /dev/null +++ b/__tests__/html2/attachment/contentType/text.html @@ -0,0 +1,53 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html2/attachment/contentType/text.html.snap-1.png b/__tests__/html2/attachment/contentType/text.html.snap-1.png new file mode 100644 index 0000000000..e8de45c8f4 Binary files /dev/null and b/__tests__/html2/attachment/contentType/text.html.snap-1.png differ diff --git a/packages/component/src/Attachment/ImageAttachment.js b/packages/component/src/Attachment/ImageAttachment.js deleted file mode 100644 index b1f84251ee..0000000000 --- a/packages/component/src/Attachment/ImageAttachment.js +++ /dev/null @@ -1,37 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import ImageContent from './ImageContent'; -import readDataURIToBlob from '../Utils/readDataURIToBlob'; - -const ImageAttachment = ({ attachment }) => { - let imageURL = attachment.thumbnailUrl || attachment.contentUrl; - - // To support Content Security Policy, data URI cannot be used. - // We need to parse the data URI into a blob: URL. - const blob = readDataURIToBlob(imageURL); - - if (blob) { - imageURL = URL.createObjectURL(blob); - } - - return ; -}; - -ImageAttachment.propTypes = { - // Either attachment.contentUrl or attachment.thumbnailUrl must be specified. - attachment: PropTypes.oneOfType([ - PropTypes.shape({ - contentUrl: PropTypes.string.isRequired, - name: PropTypes.string, - thumbnailUrl: PropTypes.string - }), - PropTypes.shape({ - contentUrl: PropTypes.string, - name: PropTypes.string, - thumbnailUrl: PropTypes.string.isRequired - }) - ]).isRequired -}; - -export default ImageAttachment; diff --git a/packages/component/src/Attachment/ImageAttachment.tsx b/packages/component/src/Attachment/ImageAttachment.tsx new file mode 100644 index 0000000000..425b01cf01 --- /dev/null +++ b/packages/component/src/Attachment/ImageAttachment.tsx @@ -0,0 +1,59 @@ +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import React, { memo } from 'react'; +import { custom, object, optional, pipe, readonly, safeParse, string, union, type InferInput } from 'valibot'; + +import readDataURIToBlob from '../Utils/readDataURIToBlob'; +import ImageContent from './ImageContent'; +import { type WebChatAttachment } from './private/types/WebChatAttachment'; + +const imageAttachmentPropsSchema = pipe( + object({ + attachment: custom( + value => + safeParse( + union([ + object({ + contentUrl: string(), + name: optional(string()), + thumbnailUrl: optional(string()) + }), + object({ + contentUrl: optional(string()), + name: optional(string()), + thumbnailUrl: string() + }) + ]), + value + ).success + ) + }), + readonly() +); + +type ImageAttachmentProps = InferInput; + +// React component is better with standard function than arrow function. +// eslint-disable-next-line prefer-arrow-callback +const ImageAttachment = memo(function ImageAttachment(props: ImageAttachmentProps) { + const { attachment } = validateProps(imageAttachmentPropsSchema, props); + + let imageURL = attachment.thumbnailUrl || attachment.contentUrl; + + // To support Content Security Policy, data URI cannot be used. + // We need to parse the data URI into a blob: URL. + const blob = readDataURIToBlob(imageURL); + + if (blob) { + // Only allow image/* for image, otherwise, treat it as binary. + // eslint-disable-next-line no-restricted-properties + imageURL = URL.createObjectURL( + new Blob([blob], { + type: blob.type.startsWith('image/') ? blob.type : 'application/octet-stream' + }) + ); + } + + return ; +}); + +export default ImageAttachment; diff --git a/packages/component/src/Styles/StyleSet/SpinnerAnimation.ts b/packages/component/src/Styles/StyleSet/SpinnerAnimation.ts index fffd55ed8d..365e36f2c1 100644 --- a/packages/component/src/Styles/StyleSet/SpinnerAnimation.ts +++ b/packages/component/src/Styles/StyleSet/SpinnerAnimation.ts @@ -15,6 +15,8 @@ export default function createSpinnerAnimationStyle({ spinnerAnimationPadding }: StrictStyleOptions) { defaultImageBlobURL || + // Content is hardcoded. + // eslint-disable-next-line no-restricted-properties (defaultImageBlobURL = URL.createObjectURL( new Blob([new Uint8Array(toByteArray(DEFAULT_IMAGE_BASE64))], { type: DEFAULT_IMAGE_TYPE }) )); diff --git a/packages/component/src/Styles/StyleSet/TypingAnimation.ts b/packages/component/src/Styles/StyleSet/TypingAnimation.ts index d2bb70dc9c..25cf8bf5e6 100644 --- a/packages/component/src/Styles/StyleSet/TypingAnimation.ts +++ b/packages/component/src/Styles/StyleSet/TypingAnimation.ts @@ -13,6 +13,8 @@ export default function createTypingAnimationStyle({ typingAnimationWidth }: StrictStyleOptions) { defaultImageBlobURL || + // Content is hardcoded. + // eslint-disable-next-line no-restricted-properties (defaultImageBlobURL = URL.createObjectURL( new Blob([new Uint8Array(toByteArray(DEFAULT_IMAGE_BASE64))], { type: DEFAULT_IMAGE_TYPE }) )); diff --git a/packages/component/src/Utils/downscaleImageToDataURL/downscaleImageToDataURLUsingBrowser.ts b/packages/component/src/Utils/downscaleImageToDataURL/downscaleImageToDataURLUsingBrowser.ts index 21af242d20..2dfc982aae 100644 --- a/packages/component/src/Utils/downscaleImageToDataURL/downscaleImageToDataURLUsingBrowser.ts +++ b/packages/component/src/Utils/downscaleImageToDataURL/downscaleImageToDataURLUsingBrowser.ts @@ -39,6 +39,8 @@ function createCanvas(width: number, height: number): HTMLCanvasElement { } function loadImageFromBlob(blob: Blob): Promise { + // Blob is only loaded in created inline and not presented in HTML. + // eslint-disable-next-line no-restricted-properties const blobURL = URL.createObjectURL(blob); return new Promise((resolve, reject) => { diff --git a/packages/component/src/Utils/readDataURIToBlob.js b/packages/component/src/Utils/readDataURIToBlob.ts similarity index 60% rename from packages/component/src/Utils/readDataURIToBlob.js rename to packages/component/src/Utils/readDataURIToBlob.ts index 4b1b9a409a..c5eaf967ae 100644 --- a/packages/component/src/Utils/readDataURIToBlob.js +++ b/packages/component/src/Utils/readDataURIToBlob.ts @@ -4,7 +4,13 @@ const PATTERN = /^data:([^,]*?)(;(base64)){0,1},([A-Za-z0-9+/=]+)/u; const DEFAULT_CONTENT_TYPE = 'text/plain;charset=US-ASCII'; -export function parse(dataURI) { +export function parse(dataURI: string): + | { + base64: string; + contentType: string; + encoding: string; + } + | undefined { const match = PATTERN.exec(dataURI); if (!match) { @@ -20,12 +26,12 @@ export function parse(dataURI) { return { base64, contentType: contentType || DEFAULT_CONTENT_TYPE, encoding }; } -export default function readDataURIToBlob(dataURI) { +export default function readDataURIToBlob(dataURI: string): Blob | undefined { const parsed = parse(dataURI); if (!parsed) { return; } - return new Blob([toByteArray(parsed.base64)], { type: parsed.contentType }); + return new Blob([toByteArray(parsed.base64).buffer as ArrayBuffer], { type: parsed.contentType }); } diff --git a/packages/component/src/hooks/useSendFiles.ts b/packages/component/src/hooks/useSendFiles.ts index b6929ec3ef..c4a3040f70 100644 --- a/packages/component/src/hooks/useSendFiles.ts +++ b/packages/component/src/hooks/useSendFiles.ts @@ -33,7 +33,10 @@ export default function useSendFiles(): (files: readonly File[]) => void { name: file.name, size: file.size, thumbnail: thumbnailURL?.toString(), - url: URL.createObjectURL(file) + // The URL is passed to chat adapter and should be treated as binary. + // Just in case the chat adapter naively echo back, it should show up as binary. + // eslint-disable-next-line no-restricted-properties + url: URL.createObjectURL(new Blob([file], { type: 'application/octet-stream' })) })) ) ) diff --git a/packages/core/src/sagas/sendMessageToPostActivitySaga.ts b/packages/core/src/sagas/sendMessageToPostActivitySaga.ts index a72bff526e..9b36e09993 100644 --- a/packages/core/src/sagas/sendMessageToPostActivitySaga.ts +++ b/packages/core/src/sagas/sendMessageToPostActivitySaga.ts @@ -12,7 +12,10 @@ function* postActivityWithMessage({ { attachments: attachments.map(({ blob, thumbnailURL }) => ({ contentType: (blob instanceof File && blob.type) || 'application/octet-stream', - contentUrl: URL.createObjectURL(blob), + // Chat adapter should download the file as binary. + // In case the chat adapter naively echo back the URL, it will be treated as binary. + // eslint-disable-next-line no-restricted-properties + contentUrl: URL.createObjectURL(new Blob([blob], { type: 'application/octet-stream' })), name: blob instanceof File ? blob.name : undefined, thumbnailUrl: thumbnailURL?.toString() })), diff --git a/packages/fluent-theme/src/components/assets/AssetComposer.tsx b/packages/fluent-theme/src/components/assets/AssetComposer.tsx index 4534120fc6..9e5c9f1a3c 100644 --- a/packages/fluent-theme/src/components/assets/AssetComposer.tsx +++ b/packages/fluent-theme/src/components/assets/AssetComposer.tsx @@ -15,6 +15,8 @@ const SLIDING_DOTS_SVG_STRING = const AssetComposer = memo(({ children }: AssetComposerProps) => { const slidingDotsURL = useMemo( + // Content is hardcoded. + // eslint-disable-next-line no-restricted-properties () => URL.createObjectURL(new Blob([SLIDING_DOTS_SVG_STRING], { type: 'image/svg+xml' })), [] );