diff --git a/wechat-media.mjs b/wechat-media.mjs index 2f8211f..78a5d70 100644 --- a/wechat-media.mjs +++ b/wechat-media.mjs @@ -131,6 +131,7 @@ export async function uploadWechatGeneratedImage( { wechatFetch = undiciFetch, publicBaseUrl = '', + allowedPublicBaseUrls = [], uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL, maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES, } = {}, @@ -138,8 +139,17 @@ export async function uploadWechatGeneratedImage( if (!accessToken) throw new Error('缺少微信 access_token'); if (!publicUrl) throw new Error('缺少生成图片公网地址'); const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString(); - if (publicBaseUrl && new URL(resolvedUrl).origin !== new URL(publicBaseUrl).origin) { - throw new Error('生成图片地址不属于当前 MindSpace 公网域名'); + const allowedOrigins = new Set(); + 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, { method: 'GET', diff --git a/wechat-media.test.mjs b/wechat-media.test.mjs index cc2c648..e467e65 100644 --- a/wechat-media.test.mjs +++ b/wechat-media.test.mjs @@ -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, /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, + }), + /可信公网域名/, + ); +}); diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs index 8c41b9a..610ff9e 100644 --- a/wechat-mp-config.mjs +++ b/wechat-mp-config.mjs @@ -36,6 +36,11 @@ export function loadWechatMpConfig(env = process.env) { env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? ''; const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? ''; 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 bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login'; return { @@ -73,6 +78,7 @@ export function loadWechatMpConfig(env = process.env) { DEFAULT_WECHAT_JSAPI_TICKET_URL, mediaPublicBaseUrl: 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)), maxFileBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_FILE_BYTES ?? 30 * 1024 * 1024)), acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0', diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 6512621..3a8d112 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1801,6 +1801,7 @@ export function createWechatMpService({ const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, { wechatFetch, publicBaseUrl: config.publicBaseUrl, + allowedPublicBaseUrls: config.generatedImagePublicBaseUrls, }); const payload = await readJsonResponse( await wechatFetch( diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 9be0d9a..452ce9e 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -859,10 +859,15 @@ test('loadWechatMpConfig requires full config and enable flag', () => { H5_WECHAT_MP_APP_SECRET: 'secret', H5_WECHAT_MP_TOKEN: 'token', H5_PUBLIC_BASE_URL: 'https://example.com', + IMGPROXY_BASE_URL: 'https://img.example.com', }); assert.equal(config.enabled, true); assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login'); 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); });