diff --git a/__tests__/components/waves/memes/MemesArtSubmissionFile.test.tsx b/__tests__/components/waves/memes/MemesArtSubmissionFile.test.tsx index f68d3f9f34..54961fa52c 100644 --- a/__tests__/components/waves/memes/MemesArtSubmissionFile.test.tsx +++ b/__tests__/components/waves/memes/MemesArtSubmissionFile.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import MemesArtSubmissionFile from '@/components/waves/memes/MemesArtSubmissionFile'; import { AuthContext } from '@/components/auth/Auth'; +import type { MemesArtSubmissionFileProps } from '@/components/waves/memes/file-upload/reducers/types'; +import type { InteractiveMediaMimeType } from '@/components/waves/memes/submission/constants/media'; // Mock framer-motion to avoid AbortSignal compatibility issues jest.mock('framer-motion', () => { @@ -62,7 +64,10 @@ jest.mock('@/components/waves/memes/file-upload/components/FilePreview', () => { )); FilePreviewMock.displayName = 'FilePreviewMock'; - return FilePreviewMock; + return { + __esModule: true, + default: FilePreviewMock, + }; }); jest.mock('@/components/waves/memes/file-upload/components/UploadArea', () => { @@ -73,7 +78,10 @@ jest.mock('@/components/waves/memes/file-upload/components/UploadArea', () => { )); UploadAreaMock.displayName = 'UploadAreaMock'; - return UploadAreaMock; + return { + __esModule: true, + default: UploadAreaMock, + }; }); jest.mock('@/components/waves/memes/file-upload/components/BrowserWarning', () => { @@ -84,7 +92,10 @@ jest.mock('@/components/waves/memes/file-upload/components/BrowserWarning', () = return
{reason}
; }); BrowserWarningMock.displayName = 'BrowserWarningMock'; - return BrowserWarningMock; + return { + __esModule: true, + default: BrowserWarningMock, + }; }); // Mock useFileUploader hook with more realistic state management @@ -152,6 +163,42 @@ describe('MemesArtSubmissionFile', () => { const mockSetToast = jest.fn(); const mockSetArtworkUploaded = jest.fn(); const mockHandleFileSelect = jest.fn(); + const mockSetMediaSource = jest.fn(); + const mockOnExternalHashChange = jest.fn(); + const mockOnExternalProviderChange = jest.fn(); +const mockOnExternalMimeTypeChange = + jest.fn<(value: InteractiveMediaMimeType) => void>(); +const mockOnClearExternalMedia = jest.fn(); + +const VALID_IPFS_CID = 'bafybeigdyrztobg3tv6zj5n6xvztf4k5p3xf7r6xkqfq5jz3o5quftdjum'; +const VALID_ARWEAVE_TX_ID = 'QW_ArkGRZa0uSmLkH2ZAzU9xOQFfGqVsRyCrND3eOo8'; +const VALID_ARWEAVE_SUBDOMAIN = 'ifx4blsbsfs22lskmlsb6zsazvhxcoibl4nkk3checvtipo6hkhq'; + + const baseProps: MemesArtSubmissionFileProps = { + artworkUploaded: false, + artworkUrl: 'url', + setArtworkUploaded: mockSetArtworkUploaded, + handleFileSelect: mockHandleFileSelect, + mediaSource: 'upload', + setMediaSource: mockSetMediaSource, + externalHash: '', + externalProvider: 'ipfs', + externalConstructedUrl: '', + externalPreviewUrl: '', + externalMimeType: 'text/html', + externalError: null, + externalValidationStatus: 'idle', + isExternalMediaValid: false, + onExternalHashChange: mockOnExternalHashChange, + onExternalProviderChange: mockOnExternalProviderChange, + onExternalMimeTypeChange: mockOnExternalMimeTypeChange, + onClearExternalMedia: mockOnClearExternalMedia, + }; + + const renderComponent = ( + overrideProps: Partial = {} + ) => + render(); beforeEach(() => { jest.clearAllMocks(); @@ -162,19 +209,17 @@ describe('MemesArtSubmissionFile', () => { mockProcessFile.mockClear(); mockHandleRetry.mockClear(); mockHandleRemoveFile.mockClear(); + mockSetMediaSource.mockClear(); + mockOnExternalHashChange.mockClear(); + mockOnExternalProviderChange.mockClear(); + mockOnExternalMimeTypeChange.mockClear(); + mockOnClearExternalMedia.mockClear(); }); describe('Error Handling - Fail Fast', () => { it('renders with default AuthContext when provider is missing', () => { // Component should render with default context values when no provider is present - const { container } = render( - - ); + const { container } = renderComponent(); // Should render the main container without errors expect(container.firstChild).toBeInTheDocument(); @@ -188,6 +233,7 @@ describe('MemesArtSubmissionFile', () => { const { container } = render( { it('shows warning when browser unsupported and calls setToast', async () => { render( - + ); @@ -236,12 +277,7 @@ describe('MemesArtSubmissionFile', () => { render( - + ); @@ -255,12 +291,7 @@ describe('MemesArtSubmissionFile', () => { it('renders upload area when artwork not uploaded', () => { render( - + ); @@ -272,10 +303,9 @@ describe('MemesArtSubmissionFile', () => { render( ); @@ -288,10 +318,9 @@ describe('MemesArtSubmissionFile', () => { const { rerender } = render( ); @@ -303,10 +332,9 @@ describe('MemesArtSubmissionFile', () => { rerender( ); @@ -321,12 +349,7 @@ describe('MemesArtSubmissionFile', () => { it('renders file input with correct attributes', () => { render( - + ); @@ -340,12 +363,7 @@ describe('MemesArtSubmissionFile', () => { it('has proper accessibility attributes on upload area', () => { render( - + ); @@ -359,10 +377,9 @@ describe('MemesArtSubmissionFile', () => { render( ); @@ -380,10 +397,8 @@ describe('MemesArtSubmissionFile', () => { render( ); @@ -395,12 +410,7 @@ describe('MemesArtSubmissionFile', () => { it('revokes object URLs on cleanup', () => { const { unmount } = render( - + ); @@ -410,4 +420,103 @@ describe('MemesArtSubmissionFile', () => { expect(true).toBe(true); // No errors during unmount }); }); + + describe('Interactive media preview security', () => { + const renderInteractivePreview = ( + overrideProps: Partial = {} + ) => + render( + + + + ); + + it('renders sandboxed iframe for approved ipfs.io URLs', () => { + renderInteractivePreview(); + + const iframe = screen.getByTitle('Interactive artwork preview'); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('src', `https://ipfs.io/ipfs/${VALID_IPFS_CID}`); + expect(iframe).toHaveAttribute('sandbox'); + expect(iframe.getAttribute('sandbox')).toContain('allow-scripts'); + }); + + it('renders sandboxed iframe for approved arweave.net URLs', () => { + renderInteractivePreview({ + externalProvider: 'arweave', + externalHash: VALID_ARWEAVE_TX_ID, + externalPreviewUrl: `https://arweave.net/${VALID_ARWEAVE_TX_ID}`, + externalConstructedUrl: `https://arweave.net/${VALID_ARWEAVE_TX_ID}`, + externalValidationStatus: 'valid', + }); + + const iframe = screen.getByTitle('Interactive artwork preview'); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('src', `https://arweave.net/${VALID_ARWEAVE_TX_ID}`); + }); + + it('renders sandboxed iframe for approved arweave subdomains', () => { + renderInteractivePreview({ + externalProvider: 'arweave', + externalHash: VALID_ARWEAVE_TX_ID, + externalPreviewUrl: `https://${VALID_ARWEAVE_SUBDOMAIN}.arweave.net/`, + externalConstructedUrl: `https://arweave.net/${VALID_ARWEAVE_TX_ID}`, + externalValidationStatus: 'valid', + }); + + const iframe = screen.getByTitle('Interactive artwork preview'); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute( + 'src', + `https://${VALID_ARWEAVE_SUBDOMAIN}.arweave.net/` + ); + }); + + it('blocks previews from unapproved domains', () => { + renderInteractivePreview({ + externalPreviewUrl: 'https://example.com/bad', + externalValidationStatus: 'valid', + isExternalMediaValid: true, + }); + + expect( + screen.getByText('Preview unavailable for unapproved domains or file types.') + ).toBeInTheDocument(); + expect(screen.queryByTitle('Interactive artwork preview')).not.toBeInTheDocument(); + }); + + it('blocks previews from allowed hosts when extension is unsafe', () => { + renderInteractivePreview({ + externalPreviewUrl: 'https://ipfs.io/ipfs/bad/file.exe', + externalValidationStatus: 'valid', + isExternalMediaValid: true, + }); + + expect( + screen.getByText('Preview unavailable for unapproved domains or file types.') + ).toBeInTheDocument(); + expect(screen.queryByTitle('Interactive artwork preview')).not.toBeInTheDocument(); + }); + it('shows validation pending message before iframe renders', () => { + renderInteractivePreview({ + isExternalMediaValid: false, + externalValidationStatus: 'pending', + externalPreviewUrl: 'https://ipfs.io/ipfs/bafyHash', + }); + + expect( + screen.getByText("Validating preview...") + ).toBeInTheDocument(); + expect(screen.queryByTitle('Interactive artwork preview')).not.toBeInTheDocument(); + }); + }); }); diff --git a/__tests__/components/waves/memes/submission/MemesArtSubmissionContainer.test.tsx b/__tests__/components/waves/memes/submission/MemesArtSubmissionContainer.test.tsx index 906fbeb132..9e4857f8d4 100644 --- a/__tests__/components/waves/memes/submission/MemesArtSubmissionContainer.test.tsx +++ b/__tests__/components/waves/memes/submission/MemesArtSubmissionContainer.test.tsx @@ -6,6 +6,7 @@ import { SubmissionStep } from '@/components/waves/memes/submission/types/Steps' import { useArtworkSubmissionForm } from '@/components/waves/memes/submission/hooks/useArtworkSubmissionForm'; import { useArtworkSubmissionMutation } from '@/components/waves/memes/submission/hooks/useArtworkSubmissionMutation'; import { useSeizeConnectContext } from '@/components/auth/SeizeConnectContext'; +import type { InteractiveMediaMimeType } from '@/components/waves/memes/submission/constants/media'; jest.mock('@/components/waves/memes/submission/hooks/useArtworkSubmissionForm'); jest.mock('@/components/waves/memes/submission/hooks/useArtworkSubmissionMutation'); @@ -27,7 +28,7 @@ describe('MemesArtSubmissionContainer', () => { beforeEach(() => { artworkProps = undefined; - mockForm.mockReturnValue({ + const formState: any = { currentStep: SubmissionStep.ARTWORK, agreements: false, setAgreements: jest.fn(), @@ -37,10 +38,88 @@ describe('MemesArtSubmissionContainer', () => { updateTraitField: jest.fn(), artworkUploaded: false, artworkUrl: '', - setArtworkUploaded: jest.fn(), - handleFileSelect: jest.fn(), - getSubmissionData: () => ({ traits: { title: 't' } }), - } as any); + selectedFile: null, + mediaSource: 'upload', + externalMediaUrl: '', + externalMediaPreviewUrl: '', + externalMediaHashInput: '', + externalMediaProvider: 'ipfs', + externalMediaMimeType: 'text/html', + externalMediaError: null, + externalMediaValidationStatus: 'idle', + isExternalMediaValid: false, + }; + + formState.setArtworkUploaded = jest.fn((value: boolean) => { + formState.artworkUploaded = value; + if (!value) { + formState.selectedFile = null; + } + }); + + formState.handleFileSelect = jest.fn((file: File) => { + formState.selectedFile = file; + formState.artworkUploaded = true; + formState.artworkUrl = 'object-url'; + }); + + formState.setMediaSource = jest.fn((mode: 'upload' | 'url') => { + formState.mediaSource = mode; + }); + + formState.setExternalMediaHash = jest.fn((hash: string) => { + formState.externalMediaHashInput = hash; + if (hash) { + formState.externalMediaUrl = `${formState.externalMediaProvider === 'arweave' ? 'https://arweave.net/' : 'ipfs://'}${hash}`; + formState.externalMediaPreviewUrl = + formState.externalMediaProvider === 'arweave' + ? `https://arweave.net/${hash}` + : `https://ipfs.io/ipfs/${hash}`; + formState.isExternalMediaValid = true; + formState.externalMediaValidationStatus = 'valid'; + formState.externalMediaError = null; + } else { + formState.externalMediaUrl = ''; + formState.externalMediaPreviewUrl = ''; + formState.isExternalMediaValid = false; + formState.externalMediaValidationStatus = 'idle'; + formState.externalMediaError = null; + } + }); + + formState.setExternalMediaProvider = jest.fn((provider: 'ipfs' | 'arweave') => { + formState.externalMediaProvider = provider; + formState.setExternalMediaHash(formState.externalMediaHashInput); + }); + + formState.setExternalMediaMimeType = jest.fn( + (mimeType: InteractiveMediaMimeType) => { + formState.externalMediaMimeType = mimeType; + } + ); + + formState.clearExternalMedia = jest.fn(() => { + formState.externalMediaHashInput = ''; + formState.externalMediaUrl = ''; + formState.externalMediaPreviewUrl = ''; + formState.isExternalMediaValid = false; + formState.externalMediaValidationStatus = 'idle'; + formState.externalMediaError = null; + }); + + formState.getSubmissionData = () => ({ traits: { title: 't' } }); + formState.getMediaSelection = jest.fn(() => ({ + mediaSource: formState.mediaSource, + selectedFile: formState.selectedFile, + externalUrl: formState.externalMediaUrl, + externalPreviewUrl: formState.externalMediaPreviewUrl, + externalProvider: formState.externalMediaProvider, + externalHash: formState.externalMediaHashInput, + externalMimeType: formState.externalMediaMimeType, + isExternalValid: formState.isExternalMediaValid, + })); + + mockForm.mockReturnValue(formState); mockMutation.mockReturnValue({ submitArtwork: jest.fn(async () => 'ok'), uploadProgress: 0, diff --git a/__tests__/components/waves/memes/submission/actions/validateInteractivePreview.test.ts b/__tests__/components/waves/memes/submission/actions/validateInteractivePreview.test.ts new file mode 100644 index 0000000000..456b302995 --- /dev/null +++ b/__tests__/components/waves/memes/submission/actions/validateInteractivePreview.test.ts @@ -0,0 +1,264 @@ +import { validateInteractivePreview } from '@/components/waves/memes/submission/actions/validateInteractivePreview'; +import { INTERACTIVE_MEDIA_GATEWAY_BASE_URL } from '@/components/waves/memes/submission/constants/security'; + +const CID_V1 = 'bafybeigdyrztobg3tv6zj5n6xvztf4k5p3xf7r6xkqfq5jz3o5quftdjum'; +const ARWEAVE_TX_ID = 'QW_ArkGRZa0uSmLkH2ZAzU9xOQFfGqVsRyCrND3eOo8'; + +const originalFetch = global.fetch; + +type MockResponse = { + status: number; + ok: boolean; + url: string; + headers: { + get: (name: string) => string | null; + }; + body: { + cancel: () => Promise; + } | null; +}; + +const createResponse = ( + status: number, + headers: Record, + url = `https://ipfs.io/ipfs/${CID_V1}` +): MockResponse => { + const headerStore = new Map( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) + ); + + const includeBody = status === 206; + + return { + status, + ok: status >= 200 && status < 300, + headers: { + get: (name: string) => headerStore.get(name.toLowerCase()) ?? null, + }, + url, + body: includeBody + ? { + cancel: async () => {}, + } + : null, + }; +}; + +describe('validateInteractivePreview', () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + it('accepts HTML content served over an approved gateway', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + createResponse(200, { 'content-type': 'text/html; charset=utf-8' }) + ); + + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(true); + expect(result.contentType).toBe('text/html; charset=utf-8'); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenLastCalledWith( + expect.stringContaining(`https://ipfs.io/ipfs/${CID_V1}`), + expect.objectContaining({ method: 'HEAD' }) + ); + }); + + it('falls back to a ranged GET when HEAD is unsupported', async () => { + (global.fetch as jest.Mock) + .mockResolvedValueOnce(createResponse(405, { 'content-type': 'text/plain' })) + .mockResolvedValueOnce( + createResponse(206, { 'content-type': 'text/html' }, `https://arweave.net/${ARWEAVE_TX_ID}`) + ); + + const result = await validateInteractivePreview({ + provider: 'arweave', + path: ARWEAVE_TX_ID, + }); + + expect(result.ok).toBe(true); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + expect.stringContaining(`https://arweave.net/${ARWEAVE_TX_ID}`), + expect.objectContaining({ method: 'GET', headers: { Range: 'bytes=0-1024' } }) + ); + }); + + it('accepts HTML content served from an arweave subdomain', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + createResponse( + 200, + { 'content-type': 'text/html' }, + `https://${ARWEAVE_TX_ID.toLowerCase()}.arweave.net/` + ) + ); + + const result = await validateInteractivePreview({ + provider: 'arweave', + path: ARWEAVE_TX_ID, + }); + + expect(result.ok).toBe(true); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('rejects responses with disallowed content types', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + createResponse(200, { 'content-type': 'application/octet-stream' }) + ); + + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('Media must respond with an HTML document.'); + }); + + it('fails when the gateway redirects to an unapproved host', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + createResponse(200, { 'content-type': 'text/html' }, 'https://example.com/bad') + ); + + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('Gateway redirected to an unapproved host.'); + }); + + it('propagates network failures as validation errors', async () => { + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('offline')); + + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('Unable to reach the content gateway.'); + }); + + it('fails fast when HEAD request returns 400 without retrying GET', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + createResponse(400, { 'content-type': 'text/plain' }) + ); + + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('Gateway returned 400.'); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('accepts responses from trailing-dot hosts after canonicalization', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + createResponse( + 200, + { 'content-type': 'text/html' }, + `https://ipfs.io./ipfs/${CID_V1}` + ) + ); + + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(true); + }); + + it('rejects responses that include credentials', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + createResponse( + 200, + { 'content-type': 'text/html' }, + `https://user:pass@ipfs.io/ipfs/${CID_V1}` + ) + ); + + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('Gateway redirected to an unapproved host.'); + }); + + it('rejects responses that use a non-default https port', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + createResponse( + 200, + { 'content-type': 'text/html' }, + `https://ipfs.io:444/ipfs/${CID_V1}` + ) + ); + + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('Gateway redirected to an unapproved host.'); + }); + + it('rejects identifiers containing path separators before requesting the gateway', async () => { + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: `${CID_V1}/index.html`, + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('Invalid path: only relative paths under the gateway origin are allowed.'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('rejects identifiers that do not match provider expectations', async () => { + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: 'not-a-valid-cid', + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('Invalid path: expected a CIDv0 or CIDv1 root hash.'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('rejects requests whose resolved target host is unapproved before making a network call', async () => { + const originalBase = INTERACTIVE_MEDIA_GATEWAY_BASE_URL.ipfs; + INTERACTIVE_MEDIA_GATEWAY_BASE_URL.ipfs = 'https://example.com/'; + + try { + const result = await validateInteractivePreview({ + provider: 'ipfs', + path: CID_V1, + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe( + 'Invalid path: resolved target is not permitted under allowed gateway hosts.' + ); + expect(global.fetch).not.toHaveBeenCalled(); + } finally { + INTERACTIVE_MEDIA_GATEWAY_BASE_URL.ipfs = originalBase; + } + }); +}); diff --git a/__tests__/components/waves/memes/submission/hooks/useArtworkSubmissionForm.test.ts b/__tests__/components/waves/memes/submission/hooks/useArtworkSubmissionForm.test.ts index cd163c45b5..1899d62a14 100644 --- a/__tests__/components/waves/memes/submission/hooks/useArtworkSubmissionForm.test.ts +++ b/__tests__/components/waves/memes/submission/hooks/useArtworkSubmissionForm.test.ts @@ -1,6 +1,8 @@ -import { renderHook, act } from '@testing-library/react'; +import { renderHook, act, waitFor } from '@testing-library/react'; import { useArtworkSubmissionForm } from '@/components/waves/memes/submission/hooks/useArtworkSubmissionForm'; +const CID_V1 = 'bafybeigdyrztobg3tv6zj5n6xvztf4k5p3xf7r6xkqfq5jz3o5quftdjum'; + jest.mock('@/components/waves/memes/traits/schema', () => ({ getInitialTraitsValues: () => ({ title: '', @@ -10,12 +12,21 @@ jest.mock('@/components/waves/memes/traits/schema', () => ({ }), })); jest.mock('@/components/auth/Auth', () => ({ useAuth: jest.fn() })); +jest.mock('@/components/waves/memes/submission/actions/validateInteractivePreview', () => ({ + validateInteractivePreview: jest.fn(), +})); const { useAuth } = require('@/components/auth/Auth'); +const { validateInteractivePreview } = require('@/components/waves/memes/submission/actions/validateInteractivePreview'); describe('useArtworkSubmissionForm', () => { beforeEach(() => { (useAuth as jest.Mock).mockReturnValue({ connectedProfile: { handle: 'alice' } }); + (validateInteractivePreview as jest.Mock).mockResolvedValue({ ok: true, contentType: 'text/html' }); + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('initializes traits from profile and updates fields', () => { @@ -38,4 +49,94 @@ describe('useArtworkSubmissionForm', () => { expect(result.current.artworkUploaded).toBe(true); expect(result.current.artworkUrl).toBe('url'); }); + + it('rejects external previews that are not HTML files', () => { + const { result } = renderHook(() => useArtworkSubmissionForm()); + + act(() => { result.current.setMediaSource('url'); }); + act(() => { result.current.setExternalMediaHash('bafyBad/binary.exe'); }); + + expect(result.current.isExternalMediaValid).toBe(false); + expect(result.current.externalMediaError).toBe('IPFS embeds must reference the root CID without subpaths.'); + expect(result.current.externalMediaPreviewUrl).toBe(''); + expect(result.current.externalMediaValidationStatus).toBe('invalid'); + expect(validateInteractivePreview).not.toHaveBeenCalled(); + }); + + it('accepts external previews that resolve to HTML documents', async () => { + const { result } = renderHook(() => useArtworkSubmissionForm()); + + act(() => { result.current.setMediaSource('url'); }); + act(() => { result.current.setExternalMediaHash(CID_V1); }); + + await waitFor(() => { + expect(result.current.isExternalMediaValid).toBe(true); + }); + + expect(validateInteractivePreview).toHaveBeenCalledWith({ + provider: 'ipfs', + path: CID_V1, + }); + expect(result.current.externalMediaError).toBeNull(); + expect(result.current.externalMediaPreviewUrl).toBe(`https://ipfs.io/ipfs/${CID_V1}`); + expect(result.current.externalMediaValidationStatus).toBe('valid'); + }); + + it('surfaces gateway validation errors from server action', async () => { + (validateInteractivePreview as jest.Mock).mockResolvedValue({ + ok: false, + reason: 'Gateway returned 404.', + }); + + const { result } = renderHook(() => useArtworkSubmissionForm()); + + act(() => { result.current.setMediaSource('url'); }); + act(() => { result.current.setExternalMediaHash(CID_V1); }); + + await waitFor(() => { + expect(result.current.externalMediaError).toBe('Gateway returned 404.'); + }); + + expect(result.current.isExternalMediaValid).toBe(false); + expect(result.current.externalMediaPreviewUrl).toBe(''); + expect(result.current.externalMediaValidationStatus).toBe('invalid'); + }); + + it('restores uploaded artwork URL when switching back from external media', async () => { + const { result } = renderHook(() => useArtworkSubmissionForm()); + class MockFileReader { + onloadend: (() => void) | null = null; + result = 'data-upload'; + readAsDataURL() { + this.onloadend?.(); + } + } + global.FileReader = MockFileReader as any; + + act(() => { + result.current.handleFileSelect(new File(['x'], 'a.png')); + }); + + expect(result.current.mediaSource).toBe('upload'); + expect(result.current.artworkUrl).toBe('data-upload'); + + act(() => { + result.current.setMediaSource('url'); + }); + act(() => { + result.current.setExternalMediaHash(CID_V1); + }); + + await waitFor(() => { + expect(result.current.isExternalMediaValid).toBe(true); + }); + expect(result.current.artworkUrl).toContain('ipfs://'); + + act(() => { + result.current.setMediaSource('upload'); + }); + + expect(result.current.artworkUrl).toBe('data-upload'); + expect(result.current.artworkUploaded).toBe(true); + }); }); diff --git a/__tests__/components/waves/memes/submission/steps/ArtworkStep.test.tsx b/__tests__/components/waves/memes/submission/steps/ArtworkStep.test.tsx index 7143c89077..338011b7a6 100644 --- a/__tests__/components/waves/memes/submission/steps/ArtworkStep.test.tsx +++ b/__tests__/components/waves/memes/submission/steps/ArtworkStep.test.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { ComponentProps } from 'react'; import { render, screen } from '@testing-library/react'; import ArtworkStep from '@/components/waves/memes/submission/steps/ArtworkStep'; import { TraitsData } from '@/components/waves/memes/submission/types/TraitsData'; +import type { InteractiveMediaMimeType } from '@/components/waves/memes/submission/constants/media'; jest.mock('@/components/waves/memes/MemesArtSubmissionFile', () => () =>
); jest.mock('@/components/waves/memes/submission/details/ArtworkDetails', () => (props: any) => ( @@ -67,20 +68,36 @@ function createTraits(): TraitsData { }; } +const createProps = ( + override: Partial> = {} +) => ({ + traits: createTraits(), + artworkUploaded: false, + artworkUrl: '', + setArtworkUploaded: () => {}, + handleFileSelect: () => {}, + mediaSource: 'upload' as const, + setMediaSource: () => {}, + externalHash: '', + externalProvider: 'ipfs', + externalConstructedUrl: '', + externalPreviewUrl: '', + externalMimeType: 'text/html' as InteractiveMediaMimeType, + externalError: null as string | null, + isExternalMediaValid: false, + onExternalHashChange: () => {}, + onExternalProviderChange: () => {}, + onExternalMimeTypeChange: (_value: InteractiveMediaMimeType) => {}, + onClearExternalMedia: () => {}, + onSubmit: () => {}, + updateTraitField: () => {}, + setTraits: () => {}, + ...override, +}); + describe('ArtworkStep', () => { it('shows upload tooltip when artwork missing', () => { - render( - {}} - handleFileSelect={() => {}} - onSubmit={() => {}} - updateTraitField={() => {}} - setTraits={() => {}} - /> - ); + render(); expect(screen.getByTestId('submit')).toBeDisabled(); expect(screen.getByTestId('submit').getAttribute('title')).toMatch('Please upload artwork'); }); @@ -108,14 +125,7 @@ describe('ArtworkStep', () => { traits.boost = 'bo'; render( {}} - handleFileSelect={() => {}} - onSubmit={() => {}} - updateTraitField={() => {}} - setTraits={() => {}} + {...createProps({ traits, artworkUploaded: true, artworkUrl: 'url' })} /> ); expect(screen.getByTestId('submit')).not.toBeDisabled(); @@ -127,14 +137,12 @@ describe('ArtworkStep', () => { const onSubmit = jest.fn(); render( {}} - handleFileSelect={() => {}} - onSubmit={onSubmit} - updateTraitField={() => {}} - setTraits={() => {}} + {...createProps({ + traits: { ...traits, title: 't', description: 'd' }, + artworkUploaded: true, + artworkUrl: 'u', + onSubmit, + })} /> ); screen.getByTestId('submit').click(); @@ -148,16 +156,13 @@ describe('ArtworkStep', () => { const onCancel = jest.fn(); render( {}} - handleFileSelect={() => {}} - onSubmit={() => {}} - onCancel={onCancel} - updateTraitField={() => {}} - setTraits={() => {}} - submissionPhase="uploading" + {...createProps({ + traits, + artworkUploaded: true, + artworkUrl: 'url', + onCancel, + submissionPhase: 'uploading', + })} /> ); const btn = screen.getByRole('button', { name: /cancel/i }); diff --git a/components/common/SandboxedExternalIframe.tsx b/components/common/SandboxedExternalIframe.tsx new file mode 100644 index 0000000000..7026315670 --- /dev/null +++ b/components/common/SandboxedExternalIframe.tsx @@ -0,0 +1,169 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { canonicalizeInteractiveMediaUrl } from "@/components/waves/memes/submission/constants/security"; + +// Sandbox policy for external interactive media: +// - allow-scripts: Required for interactive HTML content. +// - intentionally omits allow-pointer-lock, allow-same-origin and allow-popups to preserve isolation and block window spawning. +const DEFAULT_SANDBOX = "allow-scripts"; + +export interface SandboxedExternalIframeProps { + readonly src: string; + readonly title: string; + readonly className?: string; + readonly fallback?: React.ReactNode; + readonly containerClassName?: string; +} + +/** + * Render untrusted interactive media inside a strongly sandboxed iframe. + * + * Security notes: + * - keep allow-same-origin disabled to force an opaque origin boundary. + * - deny all Permission Policy features via an explicit empty `allow` attribute. + * - enforce HTTPS and strip referrers; credentialless removes ambient cookies where supported. + * - the sandbox isolates the frame even if it redirects after the initial load. + */ +const SandboxedExternalIframe: React.FC = ({ + src, + title, + className, + fallback = null, + containerClassName, +}) => { + const containerRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + const canonicalSrc = useMemo( + () => canonicalizeInteractiveMediaUrl(src), + [src] + ); + + const frameClassName = useMemo(() => { + const classes = ["tw-h-full", "tw-w-full", className].filter( + (value): value is string => Boolean(value) + ); + return classes.join(" "); + }, [className]); + + useEffect(() => { + if (!canonicalSrc) { + return; + } + + if (isVisible) { + return; + } + + const element = containerRef.current; + if (!element) { + return; + } + + if (typeof window === "undefined" || !("IntersectionObserver" in window)) { + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + (entries, observerInstance) => { + const isIntersecting = entries.some((entry) => entry.isIntersecting); + if (isIntersecting) { + setIsVisible(true); + observerInstance.disconnect(); + } + }, + { root: null, threshold: 0.1 } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [canonicalSrc, isVisible]); + + const parsedCanonicalUrl = useMemo(() => { + if (!canonicalSrc) { + return null; + } + + try { + return new URL(canonicalSrc); + } catch { + return null; + } + }, [canonicalSrc]); + + const iframeProps = useMemo(() => { + if (!canonicalSrc) { + return null; + } + + const baseProps = { + src: canonicalSrc, + title, + sandbox: DEFAULT_SANDBOX, + // `allow=""` intentionally denies all Permission Policy features beyond the sandbox defaults. + allow: "", + referrerPolicy: "no-referrer", + loading: "lazy", + } as React.IframeHTMLAttributes & { + fetchPriority?: "high" | "low" | "auto"; + credentialless?: string; + }; + + baseProps.fetchPriority = "low"; + baseProps.credentialless = ""; + baseProps.className = frameClassName; + + return baseProps; + }, [canonicalSrc, frameClassName, title]); + + if (!canonicalSrc || !iframeProps) { + return fallback ? <>{fallback} : null; + } + + const placeholder = ( +