diff --git a/furniture-3d-system/src/server/services/__tests__/product-archive-source-image.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-archive-source-image.service.test.ts index 5ac3d89b..5092af67 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-archive-source-image.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-archive-source-image.service.test.ts @@ -71,6 +71,66 @@ describe('product archive source image service', () => { expect(fetchImpl).toHaveBeenCalledTimes(2) }) + it('allows the company HTTPS image host on port 3333', async () => { + const imageUrl = 'https://static.jiansun.vip:3333/Resource/SkuImg/YMJ000904N754V01.jpg' + const fetchImpl = jest.fn().mockResolvedValue( + response({ contentType: 'image/jpeg' }), + ) as unknown as typeof fetch + const service = new ProductArchiveSourceImageService( + repository(imageUrl), + fetchImpl, + jest.fn().mockResolvedValue(['93.184.216.34']), + ) + + await expect(service.readByBarcode('YMJ000904N722')).resolves.toEqual( + expect.objectContaining({ contentType: 'image/jpeg' }), + ) + expect(fetchImpl).toHaveBeenCalledWith(new URL(imageUrl), expect.anything()) + }) + + it.each([ + 'https://images.example.com:3333/product.jpg', + 'https://static.jiansun.vip.example.com:3333/product.jpg', + 'https://static.jiansun.vip:8443/product.jpg', + 'http://static.jiansun.vip:3333/product.jpg', + ])('keeps untrusted image ports blocked: %s', async (imageUrl) => { + const fetchImpl = jest.fn() as unknown as typeof fetch + const service = new ProductArchiveSourceImageService( + repository(imageUrl), + fetchImpl, + jest.fn().mockResolvedValue(['93.184.216.34']), + ) + + await expect(service.readByBarcode('BARCODE-1')).rejects.toThrow('port is not allowed') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('keeps private addresses blocked for the trusted image host', async () => { + const fetchImpl = jest.fn() as unknown as typeof fetch + const service = new ProductArchiveSourceImageService( + repository('https://static.jiansun.vip:3333/product.jpg'), + fetchImpl, + jest.fn().mockResolvedValue(['127.0.0.1']), + ) + + await expect(service.readByBarcode('BARCODE-1')).rejects.toThrow('private address') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('validates redirects from the trusted host before following them', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + response({ status: 302, location: 'https://images.example.com:3333/product.jpg' }), + ) as unknown as typeof fetch + const service = new ProductArchiveSourceImageService( + repository('https://static.jiansun.vip:3333/product.jpg'), + fetchImpl, + jest.fn().mockResolvedValue(['93.184.216.34']), + ) + + await expect(service.readByBarcode('BARCODE-1')).rejects.toThrow('port is not allowed') + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + it('blocks source URLs that resolve to a private address', async () => { const sourceRepository = repository('http://internal.example.com/product.jpg') const fetchImpl = jest.fn() as unknown as typeof fetch diff --git a/furniture-3d-system/src/server/services/product-archive-source-image.service.ts b/furniture-3d-system/src/server/services/product-archive-source-image.service.ts index 231d21ea..0b90cea9 100644 --- a/furniture-3d-system/src/server/services/product-archive-source-image.service.ts +++ b/furniture-3d-system/src/server/services/product-archive-source-image.service.ts @@ -83,9 +83,15 @@ function isPrivateAddress(address: string) { async function assertPublicImageUrl(url: URL, resolveAddresses: ResolveAddresses) { if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Unsupported image protocol') if (url.username || url.password) throw new Error('Image URL credentials are not allowed') - if (url.port && !['80', '443'].includes(url.port)) throw new Error('Image URL port is not allowed') - const hostname = url.hostname.trim().toLowerCase() + // JST SKU photos also use the company's HTTPS image host on port 3333. + const isTrustedImagePort = url.protocol === 'https:' + && hostname === 'static.jiansun.vip' + && url.port === '3333' + if (url.port && !['80', '443'].includes(url.port) && !isTrustedImagePort) { + throw new Error('Image URL port is not allowed') + } + if (!hostname || hostname === 'localhost' || hostname.endsWith('.local')) { throw new Error('Image URL hostname is not public') }