Skip to content

Commit e449aa3

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 e449aa3

File tree

3 files changed

+67
-38
lines changed

3 files changed

+67
-38
lines changed
Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
1-
export function base64Decode(data: string): Uint8Array {
1+
/**
2+
* Safely decode a base64 string using atob().
3+
* Returns null if decoding fails (invalid base64).
4+
*/
5+
export function safeAtob(data: string): string | null {
6+
try {
7+
return atob(data);
8+
} catch {
9+
// atob throws InvalidCharacterError for invalid base64 strings
10+
return null;
11+
}
12+
}
13+
14+
/**
15+
* Decodes a base64-encoded string to a Uint8Array.
16+
* Returns null if the input is not valid base64.
17+
*/
18+
export function base64Decode(data: string): Uint8Array | null {
219
// TODO: Use Uint8Array.fromBase64 when it becomes available
320
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromBase64
4-
return Uint8Array.from(atob(data), c => c.charCodeAt(0));
21+
const decoded = safeAtob(data);
22+
if (decoded === null) {
23+
return null;
24+
}
25+
return Uint8Array.from(decoded, c => c.charCodeAt(0));
526
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ReactComponent as Sort } from "@spotlight/ui/assets/sort.svg";
22
import { ReactComponent as SortDown } from "@spotlight/ui/assets/sortDown.svg";
3+
import { safeAtob } from "@spotlight/ui/lib/base64";
34
import { cn } from "@spotlight/ui/lib/cn";
45
import Breadcrumbs from "@spotlight/ui/ui/breadcrumbs";
56
import Table from "@spotlight/ui/ui/table";
@@ -37,7 +38,7 @@ const QuerySummary = () => {
3738
const { sort, toggleSortOrder } = useSort({ defaultSortType: QUERY_SUMMARY_SORT_KEYS.totalTime });
3839

3940
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/atob
40-
const decodedType = type && atob(type);
41+
const decodedType = type ? safeAtob(type) : null;
4142

4243
const filteredDBSpans: Span[] = useMemo(() => {
4344
if (!decodedType) {

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

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { EnvelopeItem } from "@sentry/core";
22
import { ReactComponent as Download } from "@spotlight/ui/assets/download.svg";
3-
import { base64Decode } from "@spotlight/ui/lib/base64";
4-
import { type ReactNode, useCallback, useEffect, useState } from "react";
3+
import { base64Decode, safeAtob } from "@spotlight/ui/lib/base64";
4+
import { type ReactNode, useEffect, useMemo } from "react";
55
import JsonViewer from "../../shared/JsonViewer";
66
import { CodeViewer } from "./CodeViewer";
77
import { inferExtension } from "./contentType";
@@ -20,53 +20,60 @@ export default function Attachment({
2020
attachment: string;
2121
expanded?: boolean;
2222
}) {
23-
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
2423
const extension = inferExtension(header.content_type as string | null, header.type as string | null);
2524
const name = (header.filename as string) || `untitled.${extension}`;
2625

27-
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-
);
38-
const url = URL.createObjectURL(blob);
39-
setDownloadUrl(current => {
40-
if (current) {
41-
URL.revokeObjectURL(current);
42-
}
43-
return url;
44-
});
45-
return url;
46-
}, [attachment, extension, header.content_type]);
47-
48-
useEffect(() => {
26+
// Create download URL for binary content types
27+
// Returns: string (success), null (decode error)
28+
const downloadUrl = useMemo(() => {
4929
if (!expanded) {
50-
return;
30+
return undefined; // Not needed yet
5131
}
52-
if (!downloadUrl) {
53-
createDownloadUrl();
32+
33+
const contentType = header.content_type as string;
34+
let blobData: BlobPart;
35+
36+
if (IMAGE_CONTENT_TYPES.has(contentType) || VIDEO_CONTENT_TYPES.has(contentType)) {
37+
const decoded = base64Decode(attachment);
38+
if (!decoded) {
39+
return null; // Decode error
40+
}
41+
blobData = decoded.buffer as BlobPart;
42+
} else if (extension === "bin") {
43+
const decoded = safeAtob(attachment);
44+
if (decoded === null) {
45+
return null; // Decode error
46+
}
47+
blobData = decoded;
48+
} else {
49+
return undefined; // Not a binary type, no blob URL needed
5450
}
55-
}, [expanded, downloadUrl, createDownloadUrl]);
5651

57-
useEffect(
58-
() => () => {
52+
const blob = new Blob([blobData], { type: contentType || "application/octet-stream" });
53+
return URL.createObjectURL(blob);
54+
}, [expanded, attachment, extension, header.content_type]);
55+
56+
// Cleanup blob URL on unmount or when URL changes
57+
useEffect(() => {
58+
return () => {
5959
if (downloadUrl) {
6060
URL.revokeObjectURL(downloadUrl);
6161
}
62-
},
63-
[downloadUrl],
64-
);
62+
};
63+
}, [downloadUrl]);
64+
65+
const decodeError = downloadUrl === null;
6566

6667
let content: ReactNode = null;
6768

6869
if (expanded) {
69-
if (header.content_type === "text/plain" || header.content_type === "text/csv") {
70+
if (decodeError) {
71+
content = (
72+
<pre className="text-destructive-400 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2">
73+
Failed to decode attachment data. The base64 data may be corrupted or invalid.
74+
</pre>
75+
);
76+
} else if (header.content_type === "text/plain" || header.content_type === "text/csv") {
7077
content = (
7178
<pre className="text-primary-300 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2">
7279
{attachment}

0 commit comments

Comments
 (0)