Skip to content

Commit f0ba724

Browse files
committed
fix: add error handling for base64 decode operations
Fixes SPOTLIGHT-ELECTRON-4C Fixes SPOTLIGHT-ELECTRON-4G The atob() function throws InvalidCharacterError when decoding invalid base64 strings. This was causing crashes when viewing attachments with corrupted or malformed base64 data. Changes: - Update base64Decode() to return null on decode failure - Add error handling in Attachment.tsx with user-friendly error message - Add error handling in QuerySummary.tsx for URL param decode failures
1 parent a2cfbe4 commit f0ba724

File tree

3 files changed

+64
-16
lines changed

3 files changed

+64
-16
lines changed
Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
export function base64Decode(data: string): Uint8Array {
1+
/**
2+
* Decodes a base64-encoded string to a Uint8Array.
3+
* Returns null if the input is not valid base64.
4+
*/
5+
export function base64Decode(data: string): Uint8Array | null {
26
// TODO: Use Uint8Array.fromBase64 when it becomes available
37
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromBase64
4-
return Uint8Array.from(atob(data), c => c.charCodeAt(0));
8+
try {
9+
return Uint8Array.from(atob(data), c => c.charCodeAt(0));
10+
} catch {
11+
// atob throws InvalidCharacterError for invalid base64 strings
12+
return null;
13+
}
514
}

packages/spotlight/src/ui/telemetry/components/insights/QuerySummary.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,15 @@ const QuerySummary = () => {
3737
const { sort, toggleSortOrder } = useSort({ defaultSortType: QUERY_SUMMARY_SORT_KEYS.totalTime });
3838

3939
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/atob
40-
const decodedType = type && atob(type);
40+
// Wrap in try-catch to handle invalid base64 URL parameters gracefully
41+
const decodedType = useMemo(() => {
42+
if (!type) return null;
43+
try {
44+
return atob(type);
45+
} catch {
46+
return null;
47+
}
48+
}, [type]);
4149

4250
const filteredDBSpans: Span[] = useMemo(() => {
4351
if (!decodedType) {

packages/spotlight/src/ui/telemetry/components/insights/envelopes/Attachment.tsx

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ const CODE_CONTENT_TYPES = new Set(["text/css", "text/html", "text/javascript"])
1111
const IMAGE_CONTENT_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]);
1212
const VIDEO_CONTENT_TYPES = new Set(["video/mp4", "video/webm"]);
1313

14+
/**
15+
* Safely decode a base64 string using atob().
16+
* Returns null if decoding fails (invalid base64).
17+
*/
18+
function safeAtob(data: string): string | null {
19+
try {
20+
return atob(data);
21+
} catch {
22+
return null;
23+
}
24+
}
25+
1426
export default function Attachment({
1527
header,
1628
attachment,
@@ -21,20 +33,33 @@ export default function Attachment({
2133
expanded?: boolean;
2234
}) {
2335
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
36+
const [decodeError, setDecodeError] = useState<boolean>(false);
2437
const extension = inferExtension(header.content_type as string | null, header.type as string | null);
2538
const name = (header.filename as string) || `untitled.${extension}`;
2639

2740
const createDownloadUrl = useCallback(() => {
28-
const blob = new Blob(
29-
[
30-
IMAGE_CONTENT_TYPES.has(header.content_type as string) || VIDEO_CONTENT_TYPES.has(header.content_type as string)
31-
? (base64Decode(attachment).buffer as BlobPart)
32-
: extension === "bin"
33-
? atob(attachment)
34-
: attachment,
35-
],
36-
{ type: (header.content_type as string) || "application/octet-stream" },
37-
);
41+
let blobData: BlobPart;
42+
const contentType = header.content_type as string;
43+
44+
if (IMAGE_CONTENT_TYPES.has(contentType) || VIDEO_CONTENT_TYPES.has(contentType)) {
45+
const decoded = base64Decode(attachment);
46+
if (!decoded) {
47+
setDecodeError(true);
48+
return null;
49+
}
50+
blobData = decoded.buffer as BlobPart;
51+
} else if (extension === "bin") {
52+
const decoded = safeAtob(attachment);
53+
if (decoded === null) {
54+
setDecodeError(true);
55+
return null;
56+
}
57+
blobData = decoded;
58+
} else {
59+
blobData = attachment;
60+
}
61+
62+
const blob = new Blob([blobData], { type: contentType || "application/octet-stream" });
3863
const url = URL.createObjectURL(blob);
3964
setDownloadUrl(current => {
4065
if (current) {
@@ -49,10 +74,10 @@ export default function Attachment({
4974
if (!expanded) {
5075
return;
5176
}
52-
if (!downloadUrl) {
77+
if (!downloadUrl && !decodeError) {
5378
createDownloadUrl();
5479
}
55-
}, [expanded, downloadUrl, createDownloadUrl]);
80+
}, [expanded, downloadUrl, decodeError, createDownloadUrl]);
5681

5782
useEffect(
5883
() => () => {
@@ -66,7 +91,13 @@ export default function Attachment({
6691
let content: ReactNode = null;
6792

6893
if (expanded) {
69-
if (header.content_type === "text/plain" || header.content_type === "text/csv") {
94+
if (decodeError) {
95+
content = (
96+
<pre className="text-destructive-400 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2">
97+
Failed to decode attachment data. The base64 data may be corrupted or invalid.
98+
</pre>
99+
);
100+
} else if (header.content_type === "text/plain" || header.content_type === "text/csv") {
70101
content = (
71102
<pre className="text-primary-300 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2">
72103
{attachment}

0 commit comments

Comments
 (0)