Skip to content

Commit 7da7dac

Browse files
jlobue10claude
andcommitted
Make the unverified oversize-preview fallback a user setting
An NFT image too large to inline cannot be hash-verified without unbounded buffering, and the previous behavior fell back to loading it directly from its source URL in confirmation dialogs. Since the response's size and type claims are attacker-controlled, that fallback could be triggered deliberately to place unverified content in a transaction confirmation dialog. - The fallback is now opt-in via a new 'Show unverified previews' switch in Settings > NFT (off by default): disabled, oversized images simply get no preview. - The preference is stored as nftAllowUnverifiedPreviews via the existing prefs.yaml round-trip, so the main process reads the same value the renderer persists (electron/utils/allowUnverifiedNftPreviews, failing closed if the store is unreadable). - nftGetImageDataUrl consults the policy only on the MaxSizeExceededError path; verified data-URL previews are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdfCqRSBWwMpCDh1SdE24e
1 parent b7b8996 commit 7da7dac

6 files changed

Lines changed: 123 additions & 5 deletions

File tree

packages/gui/src/components/settings/SettingsNFT.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Trans } from '@lingui/macro';
1414
import { Grid, Button, Switch, FormControlLabel, Typography } from '@mui/material';
1515
import React from 'react';
1616

17+
import useAllowUnverifiedNFTPreviews from '../../hooks/useAllowUnverifiedNFTPreviews';
1718
import useCache from '../../hooks/useCache';
1819
import useHideObjectionableContent from '../../hooks/useHideObjectionableContent';
1920
import useNFTImageFittingMode from '../../hooks/useNFTImageFittingMode';
@@ -40,6 +41,7 @@ export default function SettingsGeneral() {
4041
const { cacheSize, clearCache, cacheDirectory, setCacheDirectory } = useCache();
4142
const [nftImageFittingMode, setNFTImageFittingMode] = useNFTImageFittingMode();
4243
const [nftVideoLoop, setNFTVideoLoop] = useNFTVideoLoopGlobal();
44+
const [allowUnverifiedPreviews, setAllowUnverifiedPreviews] = useAllowUnverifiedNFTPreviews();
4345
// const [, setCacheFolder] = usePrefs('cacheFolder', '');
4446
const openDialog = useOpenDialog();
4547

@@ -51,6 +53,10 @@ export default function SettingsGeneral() {
5153
setNFTVideoLoop(event.target.checked);
5254
}
5355

56+
function handleChangeAllowUnverifiedPreviews(event: React.ChangeEvent<HTMLInputElement>) {
57+
setAllowUnverifiedPreviews(event.target.checked);
58+
}
59+
5460
async function clearNFTCache() {
5561
openDialog(
5662
<ConfirmDialog
@@ -144,6 +150,28 @@ export default function SettingsGeneral() {
144150
</Grid>
145151
</Grid>
146152

153+
<Grid container>
154+
<Grid item style={{ width: '400px' }}>
155+
<SettingsTitle>
156+
<Trans>Show unverified previews</Trans>
157+
</SettingsTitle>
158+
</Grid>
159+
<Grid item container xs justifyContent="flex-end" marginTop="-6px">
160+
<FormControlLabel
161+
control={<Switch checked={allowUnverifiedPreviews} onChange={handleChangeAllowUnverifiedPreviews} />}
162+
/>
163+
</Grid>
164+
<Grid item style={{ width: '400px' }}>
165+
<SettingsText>
166+
<Trans>
167+
When an NFT image is too large to verify against its on-chain hash, load the transaction confirmation
168+
preview directly from its source URL without verification. When disabled, no preview is shown for these
169+
NFTs.
170+
</Trans>
171+
</SettingsText>
172+
</Grid>
173+
</Grid>
174+
147175
<Grid item style={{ maxWidth: '400px' }}>
148176
<Flex flexDirection="column" gap={1}>
149177
<SettingsSection>

packages/gui/src/electron/api/nftGetMetadata.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ jest.mock('../utils/fetchBuffer', () => ({
1111
jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer').MaxSizeExceededError,
1212
}));
1313

14+
const mockAllowUnverifiedNftPreviews = jest.fn<boolean, []>();
15+
16+
jest.mock('../utils/allowUnverifiedNftPreviews', () => ({
17+
__esModule: true,
18+
default: mockAllowUnverifiedNftPreviews,
19+
}));
20+
1421
const { MaxSizeExceededError } = jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer');
1522

1623
const { nftGetImageDataUrl, nftGetMetadata } =
@@ -59,6 +66,8 @@ describe('nftGetMetadata', () => {
5966
describe('nftGetImageDataUrl', () => {
6067
beforeEach(() => {
6168
mockFetchBuffer.mockReset();
69+
mockAllowUnverifiedNftPreviews.mockReset();
70+
mockAllowUnverifiedNftPreviews.mockReturnValue(false);
6271
});
6372

6473
it('returns an immutable data URL for a verified image response', async () => {
@@ -92,7 +101,18 @@ describe('nftGetImageDataUrl', () => {
92101
expect(mockFetchBuffer).not.toHaveBeenCalled();
93102
});
94103

95-
it('falls back to the direct URL when an image response exceeds the size cap', async () => {
104+
it('omits the preview for an oversized image response by default', async () => {
105+
mockFetchBuffer.mockRejectedValue(
106+
new MaxSizeExceededError({
107+
'content-type': 'image/gif',
108+
}),
109+
);
110+
111+
await expect(nftGetImageDataUrl('https://example.com/large.gif', '00')).resolves.toBeUndefined();
112+
});
113+
114+
it('falls back to the direct URL for an oversized image when unverified previews are enabled', async () => {
115+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
96116
mockFetchBuffer.mockRejectedValue(
97117
new MaxSizeExceededError({
98118
'content-type': 'image/gif',
@@ -104,7 +124,8 @@ describe('nftGetImageDataUrl', () => {
104124
);
105125
});
106126

107-
it('rejects an oversized response that is not an image', async () => {
127+
it('rejects an oversized response that is not an image even when unverified previews are enabled', async () => {
128+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
108129
mockFetchBuffer.mockRejectedValue(
109130
new MaxSizeExceededError({
110131
'content-type': 'video/mp4',

packages/gui/src/electron/api/nftGetMetadata.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
22

33
import type Headers from '../../@types/Headers';
44
import compareChecksums from '../../util/compareChecksums';
5+
import allowUnverifiedNftPreviews from '../utils/allowUnverifiedNftPreviews';
56
import fetchBuffer, { MaxSizeExceededError } from '../utils/fetchBuffer';
67

78
const METADATA_TIMEOUT = 10_000;
@@ -97,9 +98,12 @@ export async function nftGetImageDataUrl(
9798
return `data:${contentType};base64,${data.toString('base64')}`;
9899
} catch (error) {
99100
// An image too large to inline cannot be hash-verified without unbounded
100-
// buffering. Fall back to the direct URL — the dialog CSP still allows
101-
// https: images, matching the pre-verification behavior for these files.
102-
if (error instanceof MaxSizeExceededError && getImageContentType(error.headers)) {
101+
// buffering. When the user has opted in, fall back to the direct URL — the
102+
// dialog CSP still allows https: images, matching the pre-verification
103+
// behavior for these files. Off by default: the response's size and type
104+
// claims are attacker-controlled, so the fallback can be triggered
105+
// deliberately to place unverified content in a confirmation dialog.
106+
if (error instanceof MaxSizeExceededError && getImageContentType(error.headers) && allowUnverifiedNftPreviews()) {
103107
return imageUri;
104108
}
105109

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
const mockReadPrefs = jest.fn<Record<string, any>, []>();
2+
3+
jest.mock('../prefs', () => ({
4+
readPrefs: mockReadPrefs,
5+
}));
6+
7+
const { default: allowUnverifiedNftPreviews, NFT_ALLOW_UNVERIFIED_PREVIEWS_PREF } =
8+
jest.requireActual<typeof import('./allowUnverifiedNftPreviews')>('./allowUnverifiedNftPreviews');
9+
10+
describe('allowUnverifiedNftPreviews', () => {
11+
beforeEach(() => {
12+
mockReadPrefs.mockReset();
13+
});
14+
15+
it('is disabled when the preference has never been set', () => {
16+
mockReadPrefs.mockReturnValue({});
17+
18+
expect(allowUnverifiedNftPreviews()).toBe(false);
19+
});
20+
21+
it('is enabled only by an explicit boolean true', () => {
22+
mockReadPrefs.mockReturnValue({ [NFT_ALLOW_UNVERIFIED_PREVIEWS_PREF]: true });
23+
expect(allowUnverifiedNftPreviews()).toBe(true);
24+
25+
mockReadPrefs.mockReturnValue({ [NFT_ALLOW_UNVERIFIED_PREVIEWS_PREF]: 'true' });
26+
expect(allowUnverifiedNftPreviews()).toBe(false);
27+
});
28+
29+
it('fails closed when the preferences store cannot be read', () => {
30+
mockReadPrefs.mockImplementation(() => {
31+
throw new Error('userDataDir needs to be initialized');
32+
});
33+
34+
expect(allowUnverifiedNftPreviews()).toBe(false);
35+
});
36+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { readPrefs } from '../prefs';
2+
3+
// Preference key shared with the renderer's useAllowUnverifiedNFTPreviews hook.
4+
// The renderer persists it through PreferencesAPI.SAVE into prefs.yaml, which
5+
// is the copy consulted here in the main process.
6+
export const NFT_ALLOW_UNVERIFIED_PREVIEWS_PREF = 'nftAllowUnverifiedPreviews';
7+
8+
// Whether confirmation-dialog previews may fall back to loading an NFT image
9+
// directly from its source URL when the image is too large to hash-verify.
10+
// Fails closed: an unreadable preferences store (e.g. before userData is
11+
// initialized) means no unverified content is shown.
12+
export default function allowUnverifiedNftPreviews(): boolean {
13+
try {
14+
return readPrefs()[NFT_ALLOW_UNVERIFIED_PREVIEWS_PREF] === true;
15+
} catch {
16+
return false;
17+
}
18+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { usePrefs } from '@chia-network/api-react';
2+
3+
// When enabled, transaction confirmation dialogs may load an NFT preview
4+
// directly from its source URL when the image is too large to verify against
5+
// its on-chain hash. Off by default — unverifiable previews are not shown.
6+
// The main process reads the persisted copy of this preference when resolving
7+
// previews (electron/utils/allowUnverifiedNftPreviews.ts); keep the key in
8+
// sync with NFT_ALLOW_UNVERIFIED_PREVIEWS_PREF there.
9+
export default function useAllowUnverifiedNFTPreviews() {
10+
return usePrefs<boolean>('nftAllowUnverifiedPreviews', false);
11+
}

0 commit comments

Comments
 (0)