diff --git a/packages/blinks-core/src/BlinkContainer.tsx b/packages/blinks-core/src/BlinkContainer.tsx index ff234e5c..65538828 100644 --- a/packages/blinks-core/src/BlinkContainer.tsx +++ b/packages/blinks-core/src/BlinkContainer.tsx @@ -377,7 +377,9 @@ export const BlinkContainer = ({ let timeout: any; // NodeJS.Timeout const fetcher = async () => { try { - const newBlink = await blink.refresh(); + const newBlink = await blink.refresh( + normalizedSecurityLevel.actions, + ); // if after refresh user clicked started execution, we should not update the action if (executionState.status === 'idle') { diff --git a/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts b/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts index 68608b5f..f2011ed3 100644 --- a/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts +++ b/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts @@ -6,6 +6,8 @@ import { isProxified, proxify, proxifyImage, + secureFetch, + type SecurityLevel, } from '../../utils'; import { isUrlSameOrigin } from '../../utils/security.ts'; import type { BlinkAdapter } from '../BlinkAdapter.ts'; @@ -311,14 +313,20 @@ export class BlinkInstance { supportStrategy: BlinkSupportStrategy = defaultBlinkSupportStrategy, chainMetadata?: BlinkChainMetadata, id?: string, + securityLevel: SecurityLevel = 'only-trusted', ) { const { url: proxyUrl, headers: proxyHeaders } = proxify(apiUrl); - const response = await fetch(proxyUrl, { - headers: { - Accept: 'application/json', - ...proxyHeaders, + const response = await secureFetch( + proxyUrl.toString(), + { + headers: { + Accept: 'application/json', + ...proxyHeaders, + }, }, - }); + 'blink', + securityLevel, + ); if (!response.ok) { throw new Error( @@ -343,6 +351,7 @@ export class BlinkInstance { static async fetch( apiUrl: string, supportStrategy: BlinkSupportStrategy = defaultBlinkSupportStrategy, + securityLevel: SecurityLevel = 'only-trusted', ) { const id = nanoid(); return BlinkInstance._fetch( @@ -352,15 +361,17 @@ export class BlinkInstance { isChained: false, }, id, + securityLevel, ); } - refresh() { + refresh(securityLevel: SecurityLevel = 'only-trusted') { return BlinkInstance._fetch( this.url, this._supportStrategy, this._chainMetadata, this._id, + securityLevel, ); } diff --git a/packages/blinks-core/src/utils/index.ts b/packages/blinks-core/src/utils/index.ts index 30a0e06b..cd009921 100644 --- a/packages/blinks-core/src/utils/index.ts +++ b/packages/blinks-core/src/utils/index.ts @@ -6,3 +6,4 @@ export { isProxified, proxify, proxifyImage, setProxyUrl } from './proxify'; export { checkSecurity, type SecurityLevel } from './security'; export * from './supportability.ts'; export * from './url-mapper.ts'; +export * from './secure-fetch.ts'; diff --git a/packages/blinks-core/src/utils/secure-fetch.ts b/packages/blinks-core/src/utils/secure-fetch.ts new file mode 100644 index 00000000..44073a65 --- /dev/null +++ b/packages/blinks-core/src/utils/secure-fetch.ts @@ -0,0 +1,48 @@ +import { BlinksRegistry, type LookupType } from '../api'; +import { checkSecurity, type SecurityLevel } from './security.ts'; + +/** + * Fetch a resource while validating any redirect URL using BlinksRegistry. + * If a redirect response is returned, only follow it when the target URL + * is marked as `trusted` in the registry. Throws otherwise. + */ +export async function secureFetch( + url: string, + init: RequestInit & { abortController?: AbortController } = {}, + lookupType: LookupType = 'blink', + securityLevel: SecurityLevel = 'only-trusted', +): Promise { + let currentUrl = url; + let redirectCount = 0; + const { abortController, ...rest } = init; + + while (redirectCount < 5) { + const response = await fetch(currentUrl, { + ...rest, + redirect: 'manual', + signal: abortController?.signal, + }); + + if ( + response.status >= 300 && + response.status < 400 && + response.headers.has('location') + ) { + const locationHeader = response.headers.get('location')!; + const nextUrl = new URL(locationHeader, currentUrl).toString(); + const { state } = BlinksRegistry.getInstance().lookup(nextUrl, lookupType); + if (!checkSecurity(state, securityLevel)) { + throw new Error( + `Redirect target failed security validation: ${nextUrl}`, + ); + } + currentUrl = nextUrl; + redirectCount++; + continue; + } + + return response; + } + + throw new Error('Too many redirects'); +} diff --git a/packages/blinks-core/test/api/secure-fetch.spec.ts b/packages/blinks-core/test/api/secure-fetch.spec.ts new file mode 100644 index 00000000..877073a4 --- /dev/null +++ b/packages/blinks-core/test/api/secure-fetch.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, test, jest } from 'bun:test'; +import { secureFetch, BlinksRegistry, type BlinksRegistryConfig } from '../../src'; + +describe('secureFetch', () => { + test('follows redirect when target is trusted', async () => { + const fetchMock = jest.fn(async (url: RequestInfo | URL): Promise => { + const u = url.toString(); + if (u.endsWith('/redirect')) { + return new Response(null, { status: 302, headers: { location: '/final' } }); + } + if (u.endsWith('/final')) { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(null, { status: 404 }); + }); + + const config: BlinksRegistryConfig = { + actions: [{ host: 'example.com', state: 'trusted' }], + websites: [], + interstitials: [], + }; + BlinksRegistry.getInstance(config); + + const spy = jest.spyOn(globalThis, 'fetch').mockImplementation(fetchMock); + + try { + const response = await secureFetch('https://example.com/redirect'); + const data = await response.json(); + expect(data).toEqual({ ok: true }); + } finally { + spy.mockRestore(); + } + }); + + test('throws on redirect to malicious url', async () => { + const fetchMock = jest.fn(async (url: RequestInfo | URL): Promise => { + const u = url.toString(); + if (u.endsWith('/redirect')) { + return new Response(null, { + status: 302, + headers: { location: 'https://evil.com/final' }, + }); + } + return new Response(null, { status: 404 }); + }); + + const config: BlinksRegistryConfig = { + actions: [ + { host: 'example.com', state: 'trusted' }, + { host: 'evil.com', state: 'malicious' }, + ], + websites: [], + interstitials: [], + }; + BlinksRegistry.getInstance(config); + + const spy = jest.spyOn(globalThis, 'fetch').mockImplementation(fetchMock); + + try { + await expect(secureFetch('https://example.com/redirect')).rejects.toThrow(); + } finally { + spy.mockRestore(); + } + }); + + test('allows redirect to unknown url when securityLevel is non-malicious', async () => { + const fetchMock = jest.fn(async (url: RequestInfo | URL): Promise => { + const u = url.toString(); + if (u.endsWith('/redirect')) { + return new Response(null, { + status: 302, + headers: { location: 'https://unknown.com/final' }, + }); + } + if (u.endsWith('/final')) { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(null, { status: 404 }); + }); + + const config: BlinksRegistryConfig = { + actions: [{ host: 'example.com', state: 'trusted' }], + websites: [], + interstitials: [], + }; + BlinksRegistry.getInstance(config); + + const spy = jest.spyOn(globalThis, 'fetch').mockImplementation(fetchMock); + + try { + const response = await secureFetch( + 'https://example.com/redirect', + {}, + 'blink', + 'non-malicious', + ); + const data = await response.json(); + expect(data).toEqual({ ok: true }); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/blinks/src/ext/twitter.tsx b/packages/blinks/src/ext/twitter.tsx index 9b35e4a4..764916a6 100644 --- a/packages/blinks/src/ext/twitter.tsx +++ b/packages/blinks/src/ext/twitter.tsx @@ -192,6 +192,7 @@ async function handleNewNode( const blink = await BlinkInstance.fetch( blinkApiUrl, options.supportStrategy, + options.securityLevel.actions, ).catch(noop); if (!blink) {