Skip to content

Commit 1f54562

Browse files
committed
feat: feedback
1 parent e9eb308 commit 1f54562

6 files changed

Lines changed: 108 additions & 84 deletions

File tree

src/lib/file-viewer/file-viewer.css

Lines changed: 0 additions & 32 deletions
This file was deleted.

src/lib/file-viewer/index.tsx

Lines changed: 46 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import DocViewer, {
88

99
import { cn } from "../../utils";
1010
import MarkdownDocRenderer from "./markdown-viewer";
11+
import SvgDocRenderer from "./svg-viewer";
1112
import "@cyntler/react-doc-viewer/dist/index.css";
12-
import "./file-viewer.css";
1313

1414
interface FileViewerProps {
1515
/** URL of the file to display. Supports https:, http:, blob:, and data: URIs. */
@@ -20,6 +20,19 @@ interface FileViewerProps {
2020
config?: IConfig;
2121
/** Class applied to the outer wrapper. */
2222
className?: string;
23+
/**
24+
* Opt-in allowlist of `data:` URI MIME types that bypass the script-execution
25+
* gate. By default the viewer rejects `data:` URLs whose MIME can execute
26+
* code on top-frame navigation: `text/html`, `application/xhtml+xml`,
27+
* `application/xml`, `text/xml`, `image/svg+xml`.
28+
*
29+
* Pass entries here only when the URL source is trusted. `image/svg+xml`
30+
* is rendered through `<img>` (W3C secure static mode — scripts and
31+
* external references are disabled by the browser) so opting it in stays
32+
* safe; the other entries lose the defense-in-depth gate against the
33+
* fallback "open in new tab" link.
34+
*/
35+
allowedDataMimes?: readonly string[];
2336
}
2437

2538
const SAFE_URL_SCHEMES = new Set(["http:", "https:", "blob:", "data:"]);
@@ -44,8 +57,14 @@ const UNSAFE_DATA_MIMES = new Set([
4457
* Returns true if `raw` is a relative URL or uses an allowlisted scheme.
4558
* Blocks `javascript:`, `vbscript:`, `file:`, `about:`, and anything else
4659
* that could execute code or escape sandboxing when clicked.
60+
*
61+
* `allowedDataMimes` is a consumer-supplied override for the default
62+
* `UNSAFE_DATA_MIMES` blocklist — entries here pass even if blocklisted.
4763
*/
48-
const isSafeUrl = (raw: string): boolean => {
64+
const isSafeUrl = (
65+
raw: string,
66+
allowedDataMimes: ReadonlySet<string>,
67+
): boolean => {
4968
if (typeof raw !== "string" || raw.length === 0) return false;
5069
let parsed: URL;
5170
try {
@@ -71,53 +90,12 @@ const isSafeUrl = (raw: string): boolean => {
7190
} catch {
7291
return false;
7392
}
93+
if (allowedDataMimes.has(mime)) return true;
7494
if (UNSAFE_DATA_MIMES.has(mime)) return false;
7595
}
7696
return true;
7797
};
7898

79-
type PdfjsLib = {
80-
version?: string;
81-
GlobalWorkerOptions?: { workerSrc?: string };
82-
};
83-
84-
const getPdfjs = (): PdfjsLib | undefined =>
85-
typeof globalThis === "undefined"
86-
? undefined
87-
: (globalThis as unknown as { pdfjsLib?: PdfjsLib }).pdfjsLib;
88-
89-
const buildDefaultPdfWorkerSrc = (): string => {
90-
// doc-viewer bundles its own pdfjs and assigns it to globalThis.pdfjsLib at
91-
// module init; reading the version from there guarantees the worker we load
92-
// matches the runtime pdfjs (mismatched versions throw "incompatible worker").
93-
// Fallback constant is the version doc-viewer@1.17.x ships with.
94-
const version = getPdfjs()?.version ?? "4.3.136";
95-
return `https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/build/pdf.worker.min.mjs`;
96-
};
97-
98-
const setPdfWorkerSrc = (src: string): void => {
99-
const lib = getPdfjs();
100-
if (lib?.GlobalWorkerOptions && lib.GlobalWorkerOptions.workerSrc !== src) {
101-
lib.GlobalWorkerOptions.workerSrc = src;
102-
}
103-
};
104-
105-
// Module init: doc-viewer's module top-level code points workerSrc at
106-
// unpkg.com. Override it immediately so no unpkg request ever fires (the
107-
// worker is only fetched when a PDF is rendered, which is always after this).
108-
setPdfWorkerSrc(buildDefaultPdfWorkerSrc());
109-
110-
/**
111-
* Override the PDF.js worker URL globally. Call once at app startup if you
112-
* want to self-host the worker (e.g. under strict CSP) instead of using the
113-
* default version-pinned jsDelivr URL. The worker is a process-wide singleton
114-
* on `globalThis.pdfjsLib.GlobalWorkerOptions` — there is no per-instance
115-
* configuration.
116-
*/
117-
export const configurePdfWorker = (src: string): void => {
118-
setPdfWorkerSrc(src);
119-
};
120-
12199
const defaultConfig: IConfig = {
122100
header: {
123101
disableHeader: true,
@@ -142,8 +120,8 @@ const docTheme: ITheme = {
142120
const UnsupportedUrlMessage = ({ url }: { url: string }) => (
143121
<div
144122
className={cn(
145-
"text-klerosUIComponentsSecondaryText",
146-
"flex flex-col gap-2 p-6 text-sm",
123+
"text-klerosUIComponentsSecondaryText text-sm",
124+
"flex flex-col gap-2 p-6",
147125
)}
148126
>
149127
<p>Unable to display this file.</p>
@@ -160,8 +138,8 @@ const NoRendererFallback = ({
160138
}) => (
161139
<div
162140
className={cn(
163-
"text-klerosUIComponentsPrimaryText",
164-
"flex flex-col items-start gap-3 p-6 text-sm",
141+
"text-klerosUIComponentsPrimaryText text-sm",
142+
"flex flex-col items-start gap-3 p-6",
165143
)}
166144
>
167145
<p>This file type can&apos;t be previewed.</p>
@@ -190,13 +168,18 @@ function FileViewer({
190168
fileName,
191169
config,
192170
className,
171+
allowedDataMimes,
193172
}: Readonly<FileViewerProps>) {
194-
const safe = isSafeUrl(url);
173+
const allowedDataMimesSet = useMemo(
174+
() => new Set((allowedDataMimes ?? []).map((m) => m.toLowerCase())),
175+
[allowedDataMimes],
176+
);
177+
const safe = isSafeUrl(url, allowedDataMimesSet);
195178

196179
const docs = useMemo(() => [{ uri: url, fileName }], [url, fileName]);
197180

198181
const pluginRenderers = useMemo(
199-
() => [...DocViewerRenderers, MarkdownDocRenderer],
182+
() => [...DocViewerRenderers, MarkdownDocRenderer, SvgDocRenderer],
200183
[],
201184
);
202185

@@ -239,6 +222,19 @@ function FileViewer({
239222
"[&_#image-renderer]:!h-auto",
240223
"[&_#image-renderer]:!flex-none",
241224
"[&_#image-renderer]:!px-6",
225+
// Transparency checkerboard. The gradient is inlined in the
226+
// arbitrary property (rather than a theme token) so the inner
227+
// `var(--klerosUIComponentsImageCheckerColor)` resolves at this
228+
// element's cascade — consumers can override that single variable
229+
// anywhere up the tree and the color propagates. A theme-token
230+
// wrapper would bake the inner var() at `:root` and the override
231+
// would silently no-op for descendants. Full arbitrary-property
232+
// syntax (not `bg-*` shortcut) so `tailwind-merge` recognizes this
233+
// as background-image and doesn't collide with the bg-color above.
234+
// eslint-disable-next-line max-len
235+
"[&_#image-renderer]:![background-image:linear-gradient(45deg,var(--klerosUIComponentsImageCheckerColor)_25%,transparent_25%),linear-gradient(-45deg,var(--klerosUIComponentsImageCheckerColor)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,var(--klerosUIComponentsImageCheckerColor)_75%),linear-gradient(-45deg,transparent_75%,var(--klerosUIComponentsImageCheckerColor)_75%)]",
236+
"[&_#image-renderer]:![background-size:20px_20px]",
237+
"[&_#image-renderer]:![background-position:0_0,0_10px,10px_-10px,-10px_0px]",
242238
"[&_#image-img]:!max-w-full",
243239
"[&_#image-img]:!max-h-[80vh]",
244240
"[&_[class*='--loading']]:text-klerosUIComponentsSecondaryText",

src/lib/file-viewer/markdown-viewer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import ReactMarkdown from "react-markdown";
44

55
const decodeBase64Utf8 = (base64: string): string => {
66
const binary = atob(base64);
7-
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
7+
const bytes = Uint8Array.from(binary, (char) => char.codePointAt(0) ?? 0);
88
return new TextDecoder("utf-8").decode(bytes);
99
};
1010

src/lib/file-viewer/svg-viewer.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from "react";
2+
import { type DocRenderer } from "@cyntler/react-doc-viewer";
3+
4+
// Render SVG via `<img src>` rather than navigating to it or embedding it as a
5+
// document. `<img>`-loaded SVG runs in the W3C "secure static" mode: scripts,
6+
// event handlers, external `<image>`/`<use>` references, CSS `url()`, and
7+
// `<foreignObject>` are all disabled by the browser. This is the same sandbox
8+
// GitHub, Wikipedia, and Notion use for user-uploaded SVGs. Without this
9+
// renderer, DocViewer has no SVG match and falls back to the "open in new
10+
// tab" link, which top-frame-navigates to the URL and executes any scripts
11+
// in the SVG document.
12+
//
13+
// IDs match upstream image renderer ids (`#image-renderer`, `#image-img`) so
14+
// the existing arbitrary-variant styles in `index.tsx` apply uniformly.
15+
// `flex items-center justify-center` here mirrors what doc-viewer's PNG
16+
// styled-component provides for raster renderers — our element doesn't pick
17+
// up that class, so we set it inline to keep the image centered.
18+
const SvgDocRenderer: DocRenderer = ({ mainState: { currentDocument } }) => {
19+
if (!currentDocument?.uri) return null;
20+
return (
21+
<div id="image-renderer" className="flex items-center justify-center">
22+
<img
23+
id="image-img"
24+
src={currentDocument.uri}
25+
alt={currentDocument.fileName ?? ""}
26+
/>
27+
</div>
28+
);
29+
};
30+
31+
SvgDocRenderer.fileTypes = ["svg", "image/svg+xml"];
32+
SvgDocRenderer.weight = 1;
33+
34+
export default SvgDocRenderer;

src/lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,4 @@ export { default as Copiable } from "../lib/copiable";
5353

5454
export { default as DraggableList } from "../lib/draggable-list";
5555

56-
export { default as FileViewer, configurePdfWorker } from "../lib/file-viewer";
56+
export { default as FileViewer } from "../lib/file-viewer";

src/stories/file-viewer.stories.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ const PDF_URL = `${SAMPLE_FILES_BASE}/pdf-multiple-pages-file.pdf`;
2121

2222
const IMAGE_URL = `${SAMPLE_FILES_BASE}/png-image.png`;
2323

24+
// A real-world malicious-style SVG: `onload` script + external `<image>` to
25+
// exfiltrate. When rendered via `<img>` (our SvgDocRenderer), the browser
26+
// disables both — so this displays as an inert red square. If it ever leaks
27+
// to a top-frame navigation, the alert fires.
28+
const SVG_DATA_URL =
29+
"data:image/svg+xml;utf8," +
30+
encodeURIComponent(
31+
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' onload=\"alert('xss')\">" +
32+
"<rect width='100' height='100' fill='red'/>" +
33+
"<image href='https://evil.example/exfil' width='1' height='1'/>" +
34+
"</svg>",
35+
);
36+
2437
export const FileViewer: Story = {
2538
args: {
2639
themeUI: "light",
@@ -98,6 +111,19 @@ export const DataUrlSvgBlocked: Story = {
98111
},
99112
};
100113

114+
// Same URL, but the consumer opts `image/svg+xml` past the blocklist.
115+
// Renders via `<img>` (secure static mode) — `onload` does not fire and
116+
// `<image href>` to external origins is dropped.
117+
export const DataUrlSvgAllowed: Story = {
118+
args: {
119+
themeUI: "light",
120+
backgroundUI: "light",
121+
className: "w-[800px]",
122+
url: SVG_DATA_URL,
123+
allowedDataMimes: ["image/svg+xml"],
124+
},
125+
};
126+
101127
export const DataUrlXhtmlBlocked: Story = {
102128
args: {
103129
themeUI: "light",

0 commit comments

Comments
 (0)