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' })),
[]
);