Skip to content

Commit fdac604

Browse files
committed
Merge branch 'nft-3-preview-hardening' into nft-4-ipfs-gateway
# Conflicts: # packages/gui/src/components/settings/SettingsNFT.tsx # packages/gui/src/electron/api/nftGetMetadata.test.ts # packages/gui/src/electron/api/nftGetMetadata.ts
2 parents 536d9ae + 7da7dac commit fdac604

6 files changed

Lines changed: 142 additions & 10 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 useIpfsGateway from '../../hooks/useIpfsGateway';
@@ -42,6 +43,7 @@ export default function SettingsGeneral() {
4243
const [nftImageFittingMode, setNFTImageFittingMode] = useNFTImageFittingMode();
4344
const [nftVideoLoop, setNFTVideoLoop] = useNFTVideoLoopGlobal();
4445
const [ipfsGateway, setIpfsGateway] = useIpfsGateway();
46+
const [allowUnverifiedPreviews, setAllowUnverifiedPreviews] = useAllowUnverifiedNFTPreviews();
4547
// const [, setCacheFolder] = usePrefs('cacheFolder', '');
4648
const openDialog = useOpenDialog();
4749

@@ -57,6 +59,10 @@ export default function SettingsGeneral() {
5759
setIpfsGateway(event.target.checked);
5860
}
5961

62+
function handleChangeAllowUnverifiedPreviews(event: React.ChangeEvent<HTMLInputElement>) {
63+
setAllowUnverifiedPreviews(event.target.checked);
64+
}
65+
6066
async function clearNFTCache() {
6167
openDialog(
6268
<ConfirmDialog
@@ -170,6 +176,28 @@ export default function SettingsGeneral() {
170176
</Grid>
171177
</Grid>
172178

179+
<Grid container>
180+
<Grid item style={{ width: '400px' }}>
181+
<SettingsTitle>
182+
<Trans>Show unverified previews</Trans>
183+
</SettingsTitle>
184+
</Grid>
185+
<Grid item container xs justifyContent="flex-end" marginTop="-6px">
186+
<FormControlLabel
187+
control={<Switch checked={allowUnverifiedPreviews} onChange={handleChangeAllowUnverifiedPreviews} />}
188+
/>
189+
</Grid>
190+
<Grid item style={{ width: '400px' }}>
191+
<SettingsText>
192+
<Trans>
193+
When an NFT image is too large to verify against its on-chain hash, load the transaction confirmation
194+
preview directly from its source URL without verification. When disabled, no preview is shown for these
195+
NFTs.
196+
</Trans>
197+
</SettingsText>
198+
</Grid>
199+
</Grid>
200+
173201
<Grid item style={{ maxWidth: '400px' }}>
174202
<Flex flexDirection="column" gap={1}>
175203
<SettingsSection>

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ jest.mock('../utils/ipfsGateway', () => ({
1818
default: mockMaybeIpfsToGatewayUrl,
1919
}));
2020

21+
const mockAllowUnverifiedNftPreviews = jest.fn<boolean, []>();
22+
23+
jest.mock('../utils/allowUnverifiedNftPreviews', () => ({
24+
__esModule: true,
25+
default: mockAllowUnverifiedNftPreviews,
26+
}));
27+
2128
const { MaxSizeExceededError } = jest.requireActual<typeof import('../utils/fetchBuffer')>('../utils/fetchBuffer');
2229

2330
const ipfsToGatewayUrl = jest.requireActual<typeof import('../../util/ipfs')>('../../util/ipfs').default;
@@ -71,6 +78,8 @@ describe('nftGetImageDataUrl', () => {
7178
mockMaybeIpfsToGatewayUrl.mockReset();
7279
// gateway option off: URLs pass through untranslated
7380
mockMaybeIpfsToGatewayUrl.mockImplementation((url) => url);
81+
mockAllowUnverifiedNftPreviews.mockReset();
82+
mockAllowUnverifiedNftPreviews.mockReturnValue(false);
7483
});
7584

7685
it('returns an immutable data URL for a verified image response', async () => {
@@ -104,7 +113,18 @@ describe('nftGetImageDataUrl', () => {
104113
expect(mockFetchBuffer).not.toHaveBeenCalled();
105114
});
106115

107-
it('falls back to the direct URL when an image response exceeds the size cap', async () => {
116+
it('omits the preview for an oversized image response by default', async () => {
117+
mockFetchBuffer.mockRejectedValue(
118+
new MaxSizeExceededError({
119+
'content-type': 'image/gif',
120+
}),
121+
);
122+
123+
await expect(nftGetImageDataUrl('https://example.com/large.gif', '00')).resolves.toBeUndefined();
124+
});
125+
126+
it('falls back to the direct URL for an oversized image when unverified previews are enabled', async () => {
127+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
108128
mockFetchBuffer.mockRejectedValue(
109129
new MaxSizeExceededError({
110130
'content-type': 'image/gif',
@@ -116,7 +136,8 @@ describe('nftGetImageDataUrl', () => {
116136
);
117137
});
118138

119-
it('falls back to the gateway URL for an oversized ipfs image when the gateway option is on', async () => {
139+
it('falls back to the gateway URL for an oversized ipfs image when both options are on', async () => {
140+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
120141
mockMaybeIpfsToGatewayUrl.mockImplementation(ipfsToGatewayUrl);
121142
mockFetchBuffer.mockRejectedValue(
122143
new MaxSizeExceededError({
@@ -132,18 +153,32 @@ describe('nftGetImageDataUrl', () => {
132153
});
133154

134155
it('omits the preview for an oversized ipfs image while the gateway option is off', async () => {
156+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
135157
mockFetchBuffer.mockRejectedValue(
136158
new MaxSizeExceededError({
137159
'content-type': 'image/gif',
138160
}),
139161
);
140162

141163
// an untranslated ipfs URI would be blocked by the dialog CSP, so no
142-
// preview is returned at all
164+
// preview is returned at all even though unverified previews are allowed
165+
await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBeUndefined();
166+
});
167+
168+
it('omits the preview for an oversized ipfs image while unverified previews are off', async () => {
169+
mockMaybeIpfsToGatewayUrl.mockImplementation(ipfsToGatewayUrl);
170+
mockFetchBuffer.mockRejectedValue(
171+
new MaxSizeExceededError({
172+
'content-type': 'image/gif',
173+
}),
174+
);
175+
176+
// the gateway option alone does not opt into unverified fallbacks
143177
await expect(nftGetImageDataUrl('ipfs://bafybeigdyrztest/large.gif', '00')).resolves.toBeUndefined();
144178
});
145179

146-
it('rejects an oversized response that is not an image', async () => {
180+
it('rejects an oversized response that is not an image even when unverified previews are enabled', async () => {
181+
mockAllowUnverifiedNftPreviews.mockReturnValue(true);
147182
mockFetchBuffer.mockRejectedValue(
148183
new MaxSizeExceededError({
149184
'content-type': 'video/mp4',

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
33
import type Headers from '../../@types/Headers';
44
import compareChecksums from '../../util/compareChecksums';
55
import { isIpfsUrl } from '../../util/ipfs';
6+
import allowUnverifiedNftPreviews from '../utils/allowUnverifiedNftPreviews';
67
import fetchBuffer, { MaxSizeExceededError } from '../utils/fetchBuffer';
78
import maybeIpfsToGatewayUrl from '../utils/ipfsGateway';
89

@@ -99,12 +100,15 @@ export async function nftGetImageDataUrl(
99100
return `data:${contentType};base64,${data.toString('base64')}`;
100101
} catch (error) {
101102
// An image too large to inline cannot be hash-verified without unbounded
102-
// buffering. Fall back to the direct URL — the dialog CSP still allows
103-
// https: images, matching the pre-verification behavior for these files.
104-
// The CSP does not allow the ipfs: scheme, so ipfs URIs fall back to
105-
// their gateway form, and only when the user has enabled the gateway —
106-
// otherwise they get no preview rather than a CSP-blocked URL.
107-
if (error instanceof MaxSizeExceededError && getImageContentType(error.headers)) {
103+
// buffering. When the user has opted in, fall back to the direct URL — the
104+
// dialog CSP still allows https: images, matching the pre-verification
105+
// behavior for these files. Off by default: the response's size and type
106+
// claims are attacker-controlled, so the fallback can be triggered
107+
// deliberately to place unverified content in a confirmation dialog.
108+
// The CSP does not allow the ipfs: scheme either, so ipfs URIs fall back
109+
// to their gateway form, and only when the user has also enabled the
110+
// gateway — otherwise they get no preview rather than a CSP-blocked URL.
111+
if (error instanceof MaxSizeExceededError && getImageContentType(error.headers) && allowUnverifiedNftPreviews()) {
108112
const directUrl = maybeIpfsToGatewayUrl(imageUri);
109113
return isIpfsUrl(directUrl) ? undefined : directUrl;
110114
}
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)