-
Notifications
You must be signed in to change notification settings - Fork 315
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement proxy service for metadata and images (#451)
This PR implements proxy service for metadata and images. Here is needed configuration: - `NEXT_PUBLIC_METADATA_ENABLED` - flag to turn service on/off; "true" to enable - `NEXT_PUBLIC_METADATA_TIMEOUT` - abort fetching metadata for too long; default: 10_000ms - `NEXT_PUBLIC_METADATA_MAX_CONTENT_SIZE` - maximum for content size; default: 100_000 bytes - `NEXT_PUBLIC_METADATA_USER_AGENT` - will be used as user-agent header to represent Explorer's request for other services; default: Solana Explorer --------- Co-authored-by: Noah Gundotra <[email protected]>
- Loading branch information
Showing
18 changed files
with
781 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
/** | ||
* @jest-environment node | ||
*/ | ||
import _dns from 'dns'; | ||
import fetch, { Headers } from 'node-fetch'; | ||
|
||
import { GET } from '../route'; | ||
|
||
const dns = _dns.promises; | ||
|
||
function setEnvironment(key: string, value: string) { | ||
Object.assign(process.env, { ...process.env, [key]: value }); | ||
} | ||
|
||
jest.mock('node-fetch', () => { | ||
const originalFetch = jest.requireActual('node-fetch') | ||
const mockFn = jest.fn(); | ||
|
||
Object.assign(mockFn, originalFetch); | ||
|
||
return mockFn | ||
}); | ||
|
||
jest.mock('dns', () => { | ||
const originalDns = jest.requireActual('dns'); | ||
const lookupFn = jest.fn(); | ||
return { | ||
...originalDns, | ||
promises: { | ||
...originalDns.promises, | ||
lookup: lookupFn, | ||
} | ||
}; | ||
}); | ||
|
||
async function mockFileResponseOnce(data: any, headers: Headers){ | ||
// @ts-expect-error unavailable mock method for fetch | ||
fetch.mockResolvedValueOnce({ headers, json: async () => data }); | ||
} | ||
|
||
const ORIGIN = 'http://explorer.solana.com'; | ||
|
||
function requestFactory(uri?: string) { | ||
const params = new URLSearchParams({ uri: uri ?? '' }); | ||
const request = new Request(`${ORIGIN}/api/metadata/devnet?${params.toString()}`); | ||
const nextParams = { params: { network: 'devnet' } }; | ||
|
||
return { nextParams, request }; | ||
} | ||
|
||
describe('metadata/[network] endpoint', () => { | ||
const validUrl = encodeURIComponent('http://external.resource/file.json'); | ||
const unsupportedUri = encodeURIComponent('ftp://unsupported.resource/file.json'); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
}) | ||
|
||
it('should return status when disabled', async () => { | ||
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'false'); | ||
|
||
const { request, nextParams } = requestFactory(); | ||
const response = await GET(request, nextParams); | ||
expect(response.status).toBe(404); | ||
}); | ||
|
||
it('should return 400 for URIs with unsupported protocols', async () => { | ||
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true'); | ||
|
||
const request = requestFactory(unsupportedUri); | ||
const response = await GET(request.request, request.nextParams); | ||
expect(response.status).toBe(400); | ||
}); | ||
|
||
it('should return proper status upon processig data', async () => { | ||
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true') | ||
|
||
const { request, nextParams } = requestFactory(); | ||
const response = await GET(request, nextParams); | ||
expect(response.status).toBe(400); | ||
|
||
// fail on encoded incorrectly input | ||
const request2 = requestFactory('https://example.com/%E0%A4%A'); | ||
expect((await GET(request2.request, request2.nextParams)).status).toBe(400); | ||
|
||
// fail due to unexpected error | ||
const request3 = requestFactory(validUrl); | ||
const result = await GET(request3.request, request3.nextParams); | ||
expect(result.status).toBe(403); | ||
}); | ||
|
||
it('should handle valid response successfully', async () => { | ||
await mockFileResponseOnce({ attributes: [], name: "NFT" }, new Headers({ | ||
'Cache-Control': 'no-cache', | ||
'Content-Length': '140', | ||
'Content-Type': 'application/json', | ||
'Etag': 'random-etag', | ||
})); | ||
// @ts-expect-error lookup does not have mocked fn | ||
dns.lookup.mockResolvedValueOnce([{ address: '8.8.8.8' }]); | ||
|
||
const request = requestFactory(validUrl); | ||
expect((await GET(request.request, request.nextParams)).status).toBe(200); | ||
}) | ||
}); |
118 changes: 118 additions & 0 deletions
118
app/api/metadata/proxy/__tests__/fetch-resource.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
/** | ||
* @jest-environment node | ||
*/ | ||
import fetch, { Headers } from 'node-fetch'; | ||
|
||
import { fetchResource } from '../feature'; | ||
|
||
jest.mock('node-fetch', () => { | ||
const originalFetch = jest.requireActual('node-fetch') | ||
const mockFn = jest.fn(); | ||
|
||
Object.assign(mockFn, originalFetch); | ||
|
||
return mockFn | ||
}); | ||
|
||
/** | ||
* mock valid response | ||
*/ | ||
function mockFetchOnce(data: any = {}, headers: Headers = new Headers()) { | ||
// @ts-expect-error fetch does not have mocked fn | ||
fetch.mockResolvedValueOnce({ | ||
headers, | ||
json: async () => data | ||
}); | ||
} | ||
|
||
/** | ||
* mock error during process | ||
*/ | ||
function mockRejectOnce<T extends Error>(error: T) { | ||
// @ts-expect-error fetch does not have mocked fn | ||
fetch.mockRejectedValueOnce(error); | ||
} | ||
|
||
describe('fetchResource', () => { | ||
const uri = 'http://hello.world/data.json' ; | ||
const headers = new Headers({ 'Content-Type': 'application/json' }); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
}) | ||
|
||
it('should be called with proper arguments', async () => { | ||
mockFetchOnce({}, new Headers({ 'Content-Type': 'application/json, charset=utf-8' })); | ||
|
||
const resource = await fetchResource(uri, headers, 100, 100); | ||
|
||
expect(fetch).toHaveBeenCalledWith(uri, expect.anything()); | ||
expect(resource.data).toEqual({}); | ||
}) | ||
|
||
it('should throw exception for unsupported media', async () => { | ||
mockFetchOnce(); | ||
|
||
expect(() => { | ||
return fetchResource(uri, headers, 100, 100); | ||
}).rejects.toThrowError('Unsupported Media Type'); | ||
}) | ||
|
||
it('should throw exception upon exceeded size', async () => { | ||
mockRejectOnce(new Error('FetchError: content size at https://path/to/resour.ce over limit: 100')); | ||
|
||
expect(() => { | ||
return fetchResource(uri, headers, 100, 100); | ||
}).rejects.toThrowError('Max Content Size Exceeded'); | ||
}) | ||
|
||
it('should handle AbortSignal', async () => { | ||
class TimeoutError extends Error { | ||
constructor() { | ||
super() | ||
this.name = 'TimeoutError' | ||
} | ||
} | ||
mockRejectOnce(new TimeoutError()); | ||
|
||
expect(() => { | ||
return fetchResource(uri, headers, 100, 100); | ||
}).rejects.toThrowError('Gateway Timeout') | ||
}) | ||
|
||
it('should handle size overflow', async () => { | ||
mockRejectOnce(new Error('file is over limit: 100')); | ||
|
||
expect(() => { | ||
return fetchResource(uri, headers, 100, 100); | ||
}).rejects.toThrowError('Max Content Size Exceeded') | ||
}) | ||
|
||
it('should handle unexpected result', async () => { | ||
// @ts-expect-error fetch does not have mocked fn | ||
fetch.mockRejectedValueOnce({ data: "unexpected exception" }); | ||
|
||
const fn = () => { | ||
return fetchResource(uri, headers, 100, 100); | ||
} | ||
|
||
try { | ||
await fn(); | ||
} catch(e: any) { | ||
expect(e.message).toEqual('General Error') | ||
expect(e.status).toEqual(500) | ||
} | ||
}) | ||
|
||
it('should handle malformed JSON response gracefully', async () => { | ||
// Mock fetch to return a response with invalid JSON | ||
// @ts-expect-error fetch does not have mocked fn | ||
fetch.mockResolvedValueOnce({ | ||
headers: new Headers({ 'Content-Type': 'application/json' }), | ||
// Simulate malformed JSON by rejecting during json parsing | ||
json: async () => { throw new SyntaxError('Unexpected token < in JSON at position 0') } | ||
}); | ||
|
||
await expect(fetchResource(uri, headers, 100, 100)).rejects.toThrowError('Unsupported Media Type'); | ||
}); | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
/** | ||
* @jest-environment node | ||
*/ | ||
import _dns from 'dns'; | ||
|
||
import { checkURLForPrivateIP } from '../feature/ip'; | ||
|
||
const dns = _dns.promises; | ||
|
||
jest.mock('dns', () => { | ||
const originalDns = jest.requireActual('dns'); | ||
const lookupFn = jest.fn(); | ||
return { | ||
...originalDns, | ||
promises: { | ||
...originalDns.promises, | ||
lookup: lookupFn, | ||
} | ||
}; | ||
}); | ||
|
||
/** | ||
* mock valid response | ||
*/ | ||
function mockLookupOnce(addresses: { address: string }[]) { | ||
// @ts-expect-error lookup does not have mocked fn | ||
dns.lookup.mockResolvedValueOnce(addresses); | ||
} | ||
|
||
describe('ip::checkURLForPrivateIP', () => { | ||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
// do not throw exceptions forinvalid input to not break the execution flow | ||
test('should handle invalid URL gracefully', async () => { | ||
await expect(checkURLForPrivateIP('not-a-valid-url')).resolves.toBe(true); | ||
}); | ||
|
||
test('should block unsupported protocols', async () => { | ||
await expect(checkURLForPrivateIP('ftp://example.com')).resolves.toBe(true); | ||
}); | ||
|
||
test('should allow valid public URL', async () => { | ||
mockLookupOnce([{ address: '8.8.8.8' }]); | ||
expect(await checkURLForPrivateIP('http://google.com')).toBe(false); | ||
}); | ||
|
||
test('should allow valid public IPv6', async () => { | ||
mockLookupOnce([{ address: '2606:4700:4700::1111' }]); | ||
await expect(checkURLForPrivateIP('https://[2606:4700:4700::1111]')).resolves.toBe(false); | ||
}); | ||
|
||
test('should block private IPv4', async () => { | ||
mockLookupOnce([{ address: '192.168.1.1' }]); | ||
await expect(checkURLForPrivateIP('http://192.168.1.1')).resolves.toBe(true); | ||
}); | ||
|
||
test('should block localhost', async () => { | ||
mockLookupOnce([{ address: '127.0.0.1' }]); | ||
await expect(checkURLForPrivateIP('http://localhost')).resolves.toBe(true); | ||
}); | ||
|
||
test('should block decimal-encoded private IP', async () => { | ||
mockLookupOnce([{ address: '192.168.1.1' }]); | ||
await expect(checkURLForPrivateIP('http://3232235777')).resolves.toBe(true); | ||
}); | ||
|
||
test('should block hex-encoded private IP', async () => { | ||
mockLookupOnce([{ address: '192.168.1.1' }]); | ||
await expect(checkURLForPrivateIP('http://0xC0A80101')).resolves.toBe(true); | ||
}); | ||
|
||
test('should block cloud metadata IP', async () => { | ||
mockLookupOnce([{ address: '169.254.169.254' }]); | ||
await expect(checkURLForPrivateIP('http://169.254.169.254')).resolves.toBe(true); | ||
}); | ||
|
||
test('should handle DNS resolution failure gracefully', async () => { | ||
// @ts-expect-error fetch does not have mocked fn | ||
dns.lookup.mockRejectedValueOnce(new Error('DNS resolution failed')); | ||
await expect(checkURLForPrivateIP('http://unknown.domain')).resolves.toBe(true); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
export class StatusError extends Error { | ||
status: number; | ||
constructor(message: string, options: ErrorOptions & { cause: number }) { | ||
super(message); | ||
this.status = options.cause; | ||
} | ||
} | ||
|
||
export const invalidRequestError = new StatusError('Invalid Request', { cause: 400 }); | ||
|
||
export const accessDeniedError = new StatusError('Access Denied', { cause: 403 }); | ||
|
||
export const resourceNotFoundError = new StatusError('Resource Not Found', { cause: 404 }); | ||
|
||
export const maxSizeError = new StatusError('Max Content Size Exceeded', { cause: 413 }); | ||
|
||
export const unsupportedMediaError = new StatusError('Unsupported Media Type', { cause: 415 }); | ||
|
||
export const generalError = new StatusError('General Error', { cause: 500 }); | ||
|
||
export const gatewayTimeoutError = new StatusError('Gateway Timeout', { cause: 504 }); | ||
|
||
export const errors = { | ||
400: invalidRequestError, | ||
403: accessDeniedError, | ||
404: resourceNotFoundError, | ||
413: maxSizeError, | ||
415: unsupportedMediaError, | ||
500: generalError, | ||
504: gatewayTimeoutError, | ||
} | ||
|
||
export function matchAbortError(error: unknown): error is Error { | ||
return Boolean(error instanceof Error && error.name === 'AbortError'); | ||
} | ||
|
||
export function matchMaxSizeError(error: unknown): error is Error { | ||
return Boolean(error instanceof Error && error.message.match(/over limit:/)); | ||
} | ||
|
||
export function matchTimeoutError(error: unknown): error is Error { | ||
return Boolean(error instanceof Error && error.name === 'TimeoutError'); | ||
} |
Oops, something went wrong.