From 90ecdae59c676f9f013d36f7726f07cbb2e312e2 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 6 Jun 2025 01:31:07 +0300 Subject: [PATCH 1/3] test: mock fetch for secureFetch --- .../src/api/BlinkInstance/BlinkInstance.ts | 3 +- packages/blinks-core/src/utils/index.ts | 1 + .../blinks-core/src/utils/secure-fetch.ts | 46 +++++++++++++ .../blinks-core/test/api/secure-fetch.spec.ts | 68 +++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/blinks-core/src/utils/secure-fetch.ts create mode 100644 packages/blinks-core/test/api/secure-fetch.spec.ts diff --git a/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts b/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts index 68608b5f..2dc6e3e4 100644 --- a/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts +++ b/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts @@ -6,6 +6,7 @@ import { isProxified, proxify, proxifyImage, + secureFetch, } from '../../utils'; import { isUrlSameOrigin } from '../../utils/security.ts'; import type { BlinkAdapter } from '../BlinkAdapter.ts'; @@ -313,7 +314,7 @@ export class BlinkInstance { id?: string, ) { const { url: proxyUrl, headers: proxyHeaders } = proxify(apiUrl); - const response = await fetch(proxyUrl, { + const response = await secureFetch(proxyUrl.toString(), { headers: { Accept: 'application/json', ...proxyHeaders, 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..d1ec7a37 --- /dev/null +++ b/packages/blinks-core/src/utils/secure-fetch.ts @@ -0,0 +1,46 @@ +import { BlinksRegistry, type LookupType } from '../api'; + +/** + * 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', +): 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 (state !== 'trusted') { + 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..60118423 --- /dev/null +++ b/packages/blinks-core/test/api/secure-fetch.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { secureFetch, BlinksRegistry, type BlinksRegistryConfig } from '../../src'; + +function withMockedFetch(mock: typeof fetch, fn: () => Promise) { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock as any; + return fn().finally(() => { + globalThis.fetch = originalFetch; + }); +} + +describe('secureFetch', () => { + test('follows redirect when target is trusted', async () => { + const fetchMock = 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); + + await withMockedFetch(fetchMock, async () => { + const response = await secureFetch('https://example.com/redirect'); + const data = await response.json(); + expect(data).toEqual({ ok: true }); + }); + }); + + test('throws on redirect to malicious url', async () => { + const fetchMock = 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); + + await withMockedFetch(fetchMock, async () => { + await expect(secureFetch('https://example.com/redirect')).rejects.toThrow(); + }); + }); +}); From aa55d35d0ecbb3aa7311d1cd503399603405f7cf Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 6 Jun 2025 02:19:08 +0300 Subject: [PATCH 2/3] test: use jest mocks --- .../blinks-core/test/api/secure-fetch.spec.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/blinks-core/test/api/secure-fetch.spec.ts b/packages/blinks-core/test/api/secure-fetch.spec.ts index 60118423..96518d65 100644 --- a/packages/blinks-core/test/api/secure-fetch.spec.ts +++ b/packages/blinks-core/test/api/secure-fetch.spec.ts @@ -1,17 +1,9 @@ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, test, jest } from 'bun:test'; import { secureFetch, BlinksRegistry, type BlinksRegistryConfig } from '../../src'; -function withMockedFetch(mock: typeof fetch, fn: () => Promise) { - const originalFetch = globalThis.fetch; - globalThis.fetch = mock as any; - return fn().finally(() => { - globalThis.fetch = originalFetch; - }); -} - describe('secureFetch', () => { test('follows redirect when target is trusted', async () => { - const fetchMock = async (url: RequestInfo | URL): Promise => { + 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' } }); @@ -23,7 +15,7 @@ describe('secureFetch', () => { }); } return new Response(null, { status: 404 }); - }; + }); const config: BlinksRegistryConfig = { actions: [{ host: 'example.com', state: 'trusted' }], @@ -32,15 +24,19 @@ describe('secureFetch', () => { }; BlinksRegistry.getInstance(config); - await withMockedFetch(fetchMock, async () => { + 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 = async (url: RequestInfo | URL): Promise => { + const fetchMock = jest.fn(async (url: RequestInfo | URL): Promise => { const u = url.toString(); if (u.endsWith('/redirect')) { return new Response(null, { @@ -49,7 +45,7 @@ describe('secureFetch', () => { }); } return new Response(null, { status: 404 }); - }; + }); const config: BlinksRegistryConfig = { actions: [ @@ -61,8 +57,12 @@ describe('secureFetch', () => { }; BlinksRegistry.getInstance(config); - await withMockedFetch(fetchMock, async () => { + const spy = jest.spyOn(globalThis, 'fetch').mockImplementation(fetchMock); + + try { await expect(secureFetch('https://example.com/redirect')).rejects.toThrow(); - }); + } finally { + spy.mockRestore(); + } }); }); From f7482238c4b3e3abfbe864826724f66772e480ba Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 6 Jun 2025 21:35:13 +0300 Subject: [PATCH 3/3] feat: allow configurable security level for secureFetch --- packages/blinks-core/src/BlinkContainer.tsx | 4 +- .../src/api/BlinkInstance/BlinkInstance.ts | 22 +++++++--- .../blinks-core/src/utils/secure-fetch.ts | 4 +- .../blinks-core/test/api/secure-fetch.spec.ts | 41 +++++++++++++++++++ packages/blinks/src/ext/twitter.tsx | 1 + 5 files changed, 64 insertions(+), 8 deletions(-) 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 2dc6e3e4..f2011ed3 100644 --- a/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts +++ b/packages/blinks-core/src/api/BlinkInstance/BlinkInstance.ts @@ -7,6 +7,7 @@ import { proxify, proxifyImage, secureFetch, + type SecurityLevel, } from '../../utils'; import { isUrlSameOrigin } from '../../utils/security.ts'; import type { BlinkAdapter } from '../BlinkAdapter.ts'; @@ -312,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 secureFetch(proxyUrl.toString(), { - headers: { - Accept: 'application/json', - ...proxyHeaders, + const response = await secureFetch( + proxyUrl.toString(), + { + headers: { + Accept: 'application/json', + ...proxyHeaders, + }, }, - }); + 'blink', + securityLevel, + ); if (!response.ok) { throw new Error( @@ -344,6 +351,7 @@ export class BlinkInstance { static async fetch( apiUrl: string, supportStrategy: BlinkSupportStrategy = defaultBlinkSupportStrategy, + securityLevel: SecurityLevel = 'only-trusted', ) { const id = nanoid(); return BlinkInstance._fetch( @@ -353,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/secure-fetch.ts b/packages/blinks-core/src/utils/secure-fetch.ts index d1ec7a37..44073a65 100644 --- a/packages/blinks-core/src/utils/secure-fetch.ts +++ b/packages/blinks-core/src/utils/secure-fetch.ts @@ -1,4 +1,5 @@ import { BlinksRegistry, type LookupType } from '../api'; +import { checkSecurity, type SecurityLevel } from './security.ts'; /** * Fetch a resource while validating any redirect URL using BlinksRegistry. @@ -9,6 +10,7 @@ export async function secureFetch( url: string, init: RequestInit & { abortController?: AbortController } = {}, lookupType: LookupType = 'blink', + securityLevel: SecurityLevel = 'only-trusted', ): Promise { let currentUrl = url; let redirectCount = 0; @@ -29,7 +31,7 @@ export async function secureFetch( const locationHeader = response.headers.get('location')!; const nextUrl = new URL(locationHeader, currentUrl).toString(); const { state } = BlinksRegistry.getInstance().lookup(nextUrl, lookupType); - if (state !== 'trusted') { + if (!checkSecurity(state, securityLevel)) { throw new Error( `Redirect target failed security validation: ${nextUrl}`, ); diff --git a/packages/blinks-core/test/api/secure-fetch.spec.ts b/packages/blinks-core/test/api/secure-fetch.spec.ts index 96518d65..877073a4 100644 --- a/packages/blinks-core/test/api/secure-fetch.spec.ts +++ b/packages/blinks-core/test/api/secure-fetch.spec.ts @@ -65,4 +65,45 @@ describe('secureFetch', () => { 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) {