Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
529 changes: 521 additions & 8 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/evershop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@
"tailwindcss": "^4.1.18",
"touch": "^3.1.1",
"tw-animate-css": "^1.4.0",
"undici": "^7.25.0",
"uniqid": "^5.3.0",
"urql": "^3.0.3",
"uuid": "^9.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,10 @@ interface CustomerDispatchContextValue {
addressData: Omit<ExtendedCustomerAddress, 'id'>
) => Promise<ExtendedCustomerAddress>;
updateAddress: (
addressId: string | number,
uuid: string,
addressData: Partial<ExtendedCustomerAddress>
) => Promise<ExtendedCustomerAddress>;
deleteAddress: (addressId: string | number) => Promise<void>;
deleteAddress: (uuid: string) => Promise<void>;
updateProfile: (data: {
full_name?: string;
email?: string;
Expand Down Expand Up @@ -507,11 +507,11 @@ export function CustomerProvider({
// Update address function
const updateAddress = useCallback(
async (
addressId: string | number,
uuid: string,
addressData: Partial<ExtendedCustomerAddress>
): Promise<ExtendedCustomerAddress> => {
const address = state.customer?.addresses?.find(
(addr) => addr.addressId === addressId
(addr) => addr.uuid === uuid
);
if (!address?.updateApi) {
throw new Error(_('Update address API not available'));
Expand Down Expand Up @@ -553,9 +553,9 @@ export function CustomerProvider({

// Delete address function
const deleteAddress = useCallback(
async (addressId: string | number): Promise<void> => {
async (uuid: string): Promise<void> => {
const address = state.customer?.addresses?.find(
(addr) => addr.addressId === addressId
(addr) => addr.uuid === uuid
);
if (!address?.deleteApi) {
throw new Error(_('Delete address API not available'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const Address: React.FC<{
method="PATCH"
onSubmit={async (data) => {
try {
await updateAddress(address.addressId, data);
await updateAddress(address.uuid as string, data);
setDialogOpen(false);
toast.success(_('Address has been updated successfully!'));
} catch (error) {
Expand All @@ -67,6 +67,7 @@ const Address: React.FC<{
<CheckboxField
label={_('Set as default')}
defaultChecked={address.isDefault}
defaultValue={address.isDefault}
name="is_default"
/>
</div>
Expand All @@ -78,7 +79,7 @@ const Address: React.FC<{
onClick={async (e) => {
e.preventDefault();
try {
await deleteAddress(address.addressId);
await deleteAddress(address.uuid as string);
toast.success(_('Address has been deleted successfully!'));
} catch (error) {
toast.error(error.message);
Expand Down
102 changes: 102 additions & 0 deletions packages/evershop/src/lib/util/secureFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Agent, buildConnector, fetch as undiciFetch } from 'undici';

const REQUEST_TIMEOUT_MS = 15000;

/**
* Hosts the image proxy (`/images?src=…`) is allowed to fetch from, read from
* the `IMAGE_ALLOWED_HOSTS` environment variable (comma-separated). This is a
* strict allowlist: any host not listed — public or private — is refused. It
* both blocks SSRF (loopback, link-local metadata, private ranges, …) and stops
* the endpoint from being abused as a free image-optimization proxy for
* arbitrary third-party images. When unset/empty, no external host is allowed
* (only local media/public/theme images are processed).
*/
export function getAllowedImageHosts(): string[] {
return (process.env.IMAGE_ALLOWED_HOSTS || '')
.split(',')
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
}

function isAllowedHost(host: string | undefined | null): boolean {
if (!host) {
return false;
}
return getAllowedImageHosts().includes(host.toLowerCase());
}

const baseConnector = buildConnector({});

type Connector = ReturnType<typeof buildConnector>;
type ConnectorOptions = Parameters<Connector>[0];
type ConnectorCallback = Parameters<Connector>[1];

/**
* Refuse to open a connection to any host that is not on the allowlist. Because
* this runs for every connection — including each redirect hop — a redirect
* from an allowed host to a non-allowed one (e.g. an open redirect pointed at an
* internal address) is still blocked.
*/
const guardedConnector = (
options: ConnectorOptions,
callback: ConnectorCallback
): void => {
if (!isAllowedHost(options.hostname)) {
callback(
new Error(
`Blocked image fetch from non-allowlisted host: ${options.hostname}`
),
null
);
return;
}
baseConnector(options, callback);
};

const guardedAgent = new Agent({ connect: guardedConnector });

/**
* Parse and validate an untrusted image URL: http/https only, and the host must
* be on the `IMAGE_ALLOWED_HOSTS` allowlist. Returns the parsed URL.
*/
export function assertAllowedUrl(rawUrl: string): URL {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error('Invalid URL');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http and https URLs are allowed');
}
const host = url.hostname.replace(/^\[/, '').replace(/\]$/, '');
if (!isAllowedHost(host)) {
throw new Error(
`Image host "${host}" is not allowed. Add it to the IMAGE_ALLOWED_HOSTS environment variable.`
);
}
return url;
}

/**
* `fetch` for untrusted, user-supplied image URLs. Only hosts on the
* `IMAGE_ALLOWED_HOSTS` allowlist may be reached (every redirect hop included),
* restricted to http(s), with a request timeout to guard against hanging hosts.
*/
export async function secureFetch(
rawUrl: string,
init: Parameters<typeof undiciFetch>[1] = {}
): Promise<Awaited<ReturnType<typeof undiciFetch>>> {
const url = assertAllowedUrl(rawUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await undiciFetch(url, {
...init,
signal: controller.signal,
dispatcher: guardedAgent
});
} finally {
clearTimeout(timer);
}
}
79 changes: 79 additions & 0 deletions packages/evershop/src/lib/util/tests/unit/util.secureFetch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import http from 'http';
import { assertAllowedUrl, secureFetch } from '../../secureFetch.js';

describe('secureFetch image-host allowlist', () => {
const ORIGINAL = process.env.IMAGE_ALLOWED_HOSTS;
afterAll(() => {
if (ORIGINAL === undefined) {
delete process.env.IMAGE_ALLOWED_HOSTS;
} else {
process.env.IMAGE_ALLOWED_HOSTS = ORIGINAL;
}
});

describe('assertAllowedUrl', () => {
beforeEach(() => {
process.env.IMAGE_ALLOWED_HOSTS = 'cdn.example.com, 127.0.0.1';
});

it('rejects non-http(s) schemes', () => {
expect(() => assertAllowedUrl('ftp://cdn.example.com/x')).toThrow();
expect(() => assertAllowedUrl('file:///etc/passwd')).toThrow();
});

it('rejects any host not on the allowlist (public or private)', () => {
expect(() => assertAllowedUrl('http://evil.com/x')).toThrow(/not allowed/);
expect(() =>
assertAllowedUrl('http://169.254.169.254/latest/meta-data/')
).toThrow(/not allowed/);
expect(() => assertAllowedUrl('http://10.0.0.5/')).toThrow(/not allowed/);
});

it('allows hosts on the allowlist', () => {
expect(assertAllowedUrl('https://cdn.example.com/a.jpg').hostname).toBe(
'cdn.example.com'
);
expect(assertAllowedUrl('http://127.0.0.1:9000/a.jpg').hostname).toBe(
'127.0.0.1'
);
});

it('rejects everything when the allowlist is empty', () => {
process.env.IMAGE_ALLOWED_HOSTS = '';
expect(() => assertAllowedUrl('https://cdn.example.com/a.jpg')).toThrow(
/not allowed/
);
});
});

describe('secureFetch', () => {
let server;
let port;

beforeAll(async () => {
server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok');
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
port = server.address().port;
});

afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});

it('fetches from a host on the allowlist', async () => {
process.env.IMAGE_ALLOWED_HOSTS = '127.0.0.1';
const res = await secureFetch(`http://127.0.0.1:${port}/`);
expect(res.status).toBe(200);
});

it('refuses a host that is not on the allowlist', async () => {
process.env.IMAGE_ALLOWED_HOSTS = 'cdn.example.com';
await expect(
secureFetch(`http://127.0.0.1:${port}/`)
).rejects.toThrow();
});
});
});
15 changes: 14 additions & 1 deletion packages/evershop/src/modules/cms/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path';
import { JSONSchemaType } from 'ajv';
import config from 'config';
import { CONSTANTS } from '../../lib/helpers.js';
import { warning } from '../../lib/log/logger.js';
import { defaultPaginationFilters } from '../../lib/util/defaultPaginationFilters.js';
import { merge } from '../../lib/util/merge.js';
import { addProcessor } from '../../lib/util/registry.js';
Expand All @@ -10,7 +11,19 @@ import { registerDefaultPageCollectionFilters } from '../../modules/cms/services
import { registerDefaultWidgetCollectionFilters } from '../../modules/cms/services/registerDefaultWidgetCollectionFilters.js';
import { Route } from '../../types/route.js';

export default () => {
export default (context: { command?: string } = {}) => {
// Warn (non-blocking) at server boot if the image proxy allowlist is unset.
// Without IMAGE_ALLOWED_HOSTS the /images endpoint will not fetch any external
// image — only local media/public/theme images are processed.
if (
(context.command === 'start' || context.command === 'dev') &&
!process.env.IMAGE_ALLOWED_HOSTS?.trim()
) {
warning(
'IMAGE_ALLOWED_HOSTS is not set. The /images endpoint will not optimize external images — only local media/public/theme images are processed. Set IMAGE_ALLOWED_HOSTS to a comma-separated list of trusted hosts (e.g. "cdn.example.com,images.internal") to allow fetching external images.'
);
}

addProcessor('configurationSchema', (schema) => {
merge(schema, {
properties: {
Expand Down
7 changes: 5 additions & 2 deletions packages/evershop/src/modules/cms/services/imageProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';
import sharp from 'sharp';
import { CONSTANTS } from '../../../lib/helpers.js';
import { debug } from '../../../lib/log/logger.js';
import { secureFetch } from '../../../lib/util/secureFetch.js';

const hasTransparency = async (imageBuffer: Buffer): Promise<boolean> => {
try {
Expand Down Expand Up @@ -203,9 +204,11 @@ export const imageProcessor = async (
// Not in cache, fetch from external URL
}

// Fetch image from external URL
// Fetch image from external URL. `secureFetch` blocks SSRF to internal /
// non-public addresses (loopback, link-local metadata, private ranges,
// DNS rebinding, and redirects to any of those).
try {
const response = await fetch(src);
const response = await secureFetch(src);
if (!response.ok) {
throw new Error(
`Failed to fetch image: ${response.status} ${response.statusText}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ await jest.unstable_mockModule('../../../../../lib/helpers.js', () => ({
}
}));

// Mock the secureFetch module so external-URL tests don't make real requests.
// imageProcessor fetches external images through secureFetch (the SSRF guard).
await jest.unstable_mockModule('../../../../../lib/util/secureFetch.js', () => ({
secureFetch: jest.fn(),
assertAllowedUrl: jest.fn(),
getAllowedImageHosts: jest.fn(() => [])
}));
const { secureFetch } = await import('../../../../../lib/util/secureFetch.js');

// Import imageProcessor only after mocking helpers
const { imageProcessor } = await import('../../imageProcessor.js');

Expand Down Expand Up @@ -694,8 +703,8 @@ describe('Cache key generation', () => {
});

test('Should handle external URLs in cache key', async () => {
// Mock fetch for external URLs
global.fetch = jest.fn().mockResolvedValue({
// Mock secureFetch for external URLs
secureFetch.mockResolvedValue({
ok: true,
arrayBuffer: () => sharp(testImagePath).toBuffer()
});
Expand All @@ -711,8 +720,8 @@ describe('Cache key generation', () => {
const fileExistsResult = await fileExists(result.path);
expect(fileExistsResult).toBe(true);

// Clean up the global mock
delete global.fetch;
// Clean up the mock
secureFetch.mockReset();
});
});

Expand All @@ -725,17 +734,17 @@ describe('Error handling', () => {
});

test('Should handle external URL fetch errors gracefully', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
secureFetch.mockRejectedValue(new Error('Network error'));

await expect(
imageProcessor('https://example.com/image.jpg', 200, 80)
).rejects.toThrow('Image not found or processing failed');

delete global.fetch;
secureFetch.mockReset();
});

test('Should handle unsuccessful HTTP response', async () => {
global.fetch = jest.fn().mockResolvedValue({
secureFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found'
Expand All @@ -745,7 +754,7 @@ describe('Error handling', () => {
imageProcessor('https://example.com/image.jpg', 200, 80)
).rejects.toThrow('Image not found or processing failed');

delete global.fetch;
secureFetch.mockReset();
});

test('Should handle invalid URL format', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"methods": ["POST"],
"path": "/customers/:customer_id/addresses",
"access": "public"
"access": "private"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import bodyParser from 'body-parser';
import { EvershopRequest } from '../../../../types/request.js';
import { EvershopResponse } from '../../../../types/response.js';

export default (
request: EvershopRequest,
response: EvershopResponse,
next: () => void
) => {
bodyParser.json({ inflate: false })(request, response, next);
};
Loading
Loading