Skip to content

Commit 6ffe600

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 6ffe600

File tree

3 files changed

+58
-17
lines changed

3 files changed

+58
-17
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: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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";
3+
import { base64Decode, safeAtob } from "@spotlight/ui/lib/base64";
44
import { type ReactNode, useCallback, useEffect, useState } from "react";
55
import JsonViewer from "../../shared/JsonViewer";
66
import { CodeViewer } from "./CodeViewer";
@@ -21,20 +21,33 @@ export default function Attachment({
2121
expanded?: boolean;
2222
}) {
2323
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
24+
const [decodeError, setDecodeError] = useState<boolean>(false);
2425
const extension = inferExtension(header.content_type as string | null, header.type as string | null);
2526
const name = (header.filename as string) || `untitled.${extension}`;
2627

2728
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-
);
29+
let blobData: BlobPart;
30+
const contentType = header.content_type as string;
31+
32+
if (IMAGE_CONTENT_TYPES.has(contentType) || VIDEO_CONTENT_TYPES.has(contentType)) {
33+
const decoded = base64Decode(attachment);
34+
if (!decoded) {
35+
setDecodeError(true);
36+
return null;
37+
}
38+
blobData = decoded.buffer as BlobPart;
39+
} else if (extension === "bin") {
40+
const decoded = safeAtob(attachment);
41+
if (decoded === null) {
42+
setDecodeError(true);
43+
return null;
44+
}
45+
blobData = decoded;
46+
} else {
47+
blobData = attachment;
48+
}
49+
50+
const blob = new Blob([blobData], { type: contentType || "application/octet-stream" });
3851
const url = URL.createObjectURL(blob);
3952
setDownloadUrl(current => {
4053
if (current) {
@@ -49,10 +62,10 @@ export default function Attachment({
4962
if (!expanded) {
5063
return;
5164
}
52-
if (!downloadUrl) {
65+
if (!downloadUrl && !decodeError) {
5366
createDownloadUrl();
5467
}
55-
}, [expanded, downloadUrl, createDownloadUrl]);
68+
}, [expanded, downloadUrl, decodeError, createDownloadUrl]);
5669

5770
useEffect(
5871
() => () => {
@@ -66,7 +79,13 @@ export default function Attachment({
6679
let content: ReactNode = null;
6780

6881
if (expanded) {
69-
if (header.content_type === "text/plain" || header.content_type === "text/csv") {
82+
if (decodeError) {
83+
content = (
84+
<pre className="text-destructive-400 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2">
85+
Failed to decode attachment data. The base64 data may be corrupted or invalid.
86+
</pre>
87+
);
88+
} else if (header.content_type === "text/plain" || header.content_type === "text/csv") {
7089
content = (
7190
<pre className="text-primary-300 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2">
7291
{attachment}

0 commit comments

Comments
 (0)