Compare commits

...

1 Commits

Author SHA1 Message Date
john 60a45c1c59 fix(wechat): trust configured imgproxy generated images
Memind CI / Test, build, and release guards (pull_request) Failing after 12m23s
2026-07-21 23:57:01 +08:00
5 changed files with 59 additions and 2 deletions
+12 -2
View File
@@ -131,6 +131,7 @@ export async function uploadWechatGeneratedImage(
{ {
wechatFetch = undiciFetch, wechatFetch = undiciFetch,
publicBaseUrl = '', publicBaseUrl = '',
allowedPublicBaseUrls = [],
uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL, uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL,
maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES, maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES,
} = {}, } = {},
@@ -138,8 +139,17 @@ export async function uploadWechatGeneratedImage(
if (!accessToken) throw new Error('缺少微信 access_token'); if (!accessToken) throw new Error('缺少微信 access_token');
if (!publicUrl) throw new Error('缺少生成图片公网地址'); if (!publicUrl) throw new Error('缺少生成图片公网地址');
const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString(); const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString();
if (publicBaseUrl && new URL(resolvedUrl).origin !== new URL(publicBaseUrl).origin) { const allowedOrigins = new Set();
throw new Error('生成图片地址不属于当前 MindSpace 公网域名'); for (const baseUrl of [publicBaseUrl, ...allowedPublicBaseUrls]) {
if (!baseUrl) continue;
try {
allowedOrigins.add(new URL(String(baseUrl)).origin);
} catch {
// Ignore invalid optional bases; at least one valid configured origin is required below.
}
}
if (allowedOrigins.size > 0 && !allowedOrigins.has(new URL(resolvedUrl).origin)) {
throw new Error('生成图片地址不属于当前 MindSpace 可信公网域名');
} }
const sourceResponse = await wechatFetch(resolvedUrl, { const sourceResponse = await wechatFetch(resolvedUrl, {
method: 'GET', method: 'GET',
+35
View File
@@ -36,3 +36,38 @@ test('uploadWechatGeneratedImage converts a generated asset and uploads WeChat i
assert.match(calls[1].url, /access_token=access-1/); assert.match(calls[1].url, /access_token=access-1/);
assert.match(calls[1].url, /type=image/); assert.match(calls[1].url, /type=image/);
}); });
test('uploadWechatGeneratedImage accepts configured imgproxy origin and rejects unknown origins', async () => {
const source = await sharp({
create: { width: 16, height: 16, channels: 3, background: '#884422' },
}).png().toBuffer();
const wechatFetch = async (url) => {
if (String(url).startsWith('https://img.example.com/')) {
return new Response(source, { status: 200, headers: { 'Content-Type': 'image/png' } });
}
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-media-imgproxy' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
const accepted = await uploadWechatGeneratedImage(
'access-2',
'https://img.example.com/signed/generated.webp',
{
publicBaseUrl: 'https://app.example.com',
allowedPublicBaseUrls: ['https://img.example.com'],
wechatFetch,
},
);
assert.equal(accepted.mediaId, 'wx-media-imgproxy');
await assert.rejects(
uploadWechatGeneratedImage('access-2', 'https://untrusted.example.net/generated.webp', {
publicBaseUrl: 'https://app.example.com',
allowedPublicBaseUrls: ['https://img.example.com'],
wechatFetch,
}),
/可信公网域名/,
);
});
+6
View File
@@ -36,6 +36,11 @@ export function loadWechatMpConfig(env = process.env) {
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? ''; env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? ''; const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? ''; const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
const generatedImagePublicBaseUrls = [...new Set([
publicBaseUrl,
env.IMGPROXY_BASE_URL?.trim()?.replace(/\/$/, '') ?? '',
...parseCsvList(env.H5_WECHAT_MP_GENERATED_IMAGE_BASE_URLS).map((value) => value.replace(/\/$/, '')),
].filter(Boolean))];
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1'; const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login'; const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
return { return {
@@ -73,6 +78,7 @@ export function loadWechatMpConfig(env = process.env) {
DEFAULT_WECHAT_JSAPI_TICKET_URL, DEFAULT_WECHAT_JSAPI_TICKET_URL,
mediaPublicBaseUrl: mediaPublicBaseUrl:
env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl, env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl,
generatedImagePublicBaseUrls,
maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)), maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)),
maxFileBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_FILE_BYTES ?? 30 * 1024 * 1024)), maxFileBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_FILE_BYTES ?? 30 * 1024 * 1024)),
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0', acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
+1
View File
@@ -1801,6 +1801,7 @@ export function createWechatMpService({
const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, { const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, {
wechatFetch, wechatFetch,
publicBaseUrl: config.publicBaseUrl, publicBaseUrl: config.publicBaseUrl,
allowedPublicBaseUrls: config.generatedImagePublicBaseUrls,
}); });
const payload = await readJsonResponse( const payload = await readJsonResponse(
await wechatFetch( await wechatFetch(
+5
View File
@@ -859,10 +859,15 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
H5_WECHAT_MP_APP_SECRET: 'secret', H5_WECHAT_MP_APP_SECRET: 'secret',
H5_WECHAT_MP_TOKEN: 'token', H5_WECHAT_MP_TOKEN: 'token',
H5_PUBLIC_BASE_URL: 'https://example.com', H5_PUBLIC_BASE_URL: 'https://example.com',
IMGPROXY_BASE_URL: 'https://img.example.com',
}); });
assert.equal(config.enabled, true); assert.equal(config.enabled, true);
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login'); assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
assert.equal(config.requireFreshPageThumbnail, true); assert.equal(config.requireFreshPageThumbnail, true);
assert.deepEqual(config.generatedImagePublicBaseUrls, [
'https://example.com',
'https://img.example.com',
]);
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false); assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
}); });