Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .eslintrc.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 53 additions & 0 deletions __tests__/html2/attachment/contentType/binary.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(async function () {
const {
testHelpers: { createDirectLineEmulator }
} = window;

const { directLine, store } = createDirectLineEmulator();

WebChat.renderWebChat({ directLine, store }, document.getElementById('webchat'));

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
attachments: [
{
contentType: 'image/png',
contentUrl:
'data:application/octet-stream;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAK0lEQVQ4T2P8z8Dwn4GKgHHUQIpDczQMKQ5ChtEwHA1DMkJgNNmQEWhoWgBMAiftPRtHngAAAABJRU5ErkJggg=='
}
],
type: 'message'
});

const imageElement = pageElements.activities()[0].querySelector('img');

const imageURL = imageElement.getAttribute('src');

// THEN: This test is only relevant it is a blob URL.
expect(imageURL.startsWith('blob:')).toEqual(true);

const res = await fetch(imageURL);

const contentType = res.headers.get('content-type');

// THEN: Content type should be "application/octet-stream".
expect(contentType).toBe('application/octet-stream');

// THEN: Should show the image properly.
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions __tests__/html2/attachment/contentType/png.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(async function () {
const {
testHelpers: { createDirectLineEmulator }
} = window;

const { directLine, store } = createDirectLineEmulator();

WebChat.renderWebChat({ directLine, store }, document.getElementById('webchat'));

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
attachments: [
{
contentType: 'image/png',
contentUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAK0lEQVQ4T2P8z8Dwn4GKgHHUQIpDczQMKQ5ChtEwHA1DMkJgNNmQEWhoWgBMAiftPRtHngAAAABJRU5ErkJggg=='
}
],
type: 'message'
});

const imageElement = pageElements.activities()[0].querySelector('img');

const imageURL = imageElement.getAttribute('src');

// THEN: This test is only relevant it is a blob URL.
expect(imageURL.startsWith('blob:')).toEqual(true);

const res = await fetch(imageURL);

const contentType = res.headers.get('content-type');

// THEN: Content type should be "image/png".
expect(contentType).toBe('image/png');

// THEN: Should show the image properly.
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions __tests__/html2/attachment/contentType/text.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(async function () {
const {
testHelpers: { createDirectLineEmulator }
} = window;

const { directLine, store } = createDirectLineEmulator();

WebChat.renderWebChat({ directLine, store }, document.getElementById('webchat'));

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
attachments: [
{
contentType: 'image/png',
contentUrl:
'data:text/plain;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAK0lEQVQ4T2P8z8Dwn4GKgHHUQIpDczQMKQ5ChtEwHA1DMkJgNNmQEWhoWgBMAiftPRtHngAAAABJRU5ErkJggg=='
}
],
type: 'message'
});

const imageElement = pageElements.activities()[0].querySelector('img');

const imageURL = imageElement.getAttribute('src');

// THEN: This test is only relevant it is a blob URL.
expect(imageURL.startsWith('blob:')).toEqual(true);

const res = await fetch(imageURL);

const contentType = res.headers.get('content-type');

// THEN: Content type should be rectified to "application/octet-stream".
expect(contentType).toBe('application/octet-stream');

// THEN: Should show the image properly.
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 0 additions & 37 deletions packages/component/src/Attachment/ImageAttachment.js

This file was deleted.

59 changes: 59 additions & 0 deletions packages/component/src/Attachment/ImageAttachment.tsx
Original file line number Diff line number Diff line change
@@ -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<WebChatAttachment>(
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<typeof imageAttachmentPropsSchema>;

// 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 <ImageContent alt={attachment.name} src={imageURL} />;
});

export default ImageAttachment;
2 changes: 2 additions & 0 deletions packages/component/src/Styles/StyleSet/SpinnerAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
));
Expand Down
2 changes: 2 additions & 0 deletions packages/component/src/Styles/StyleSet/TypingAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ function createCanvas(width: number, height: number): HTMLCanvasElement {
}

function loadImageFromBlob(blob: Blob): Promise<HTMLImageElement> {
// Blob is only loaded in <img> created inline and not presented in HTML.
// eslint-disable-next-line no-restricted-properties
const blobURL = URL.createObjectURL(blob);

return new Promise<HTMLImageElement>((resolve, reject) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 });
Comment thread
OEvgeny marked this conversation as resolved.
}
5 changes: 4 additions & 1 deletion packages/component/src/hooks/useSendFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))
Comment thread
compulim marked this conversation as resolved.
}))
)
)
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/sagas/sendMessageToPostActivitySaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' })),
[]
);
Expand Down
Loading