feat: add secure remote channel media

This commit is contained in:
lofyer
2026-08-09 20:33:35 +08:00
parent 6c891f3522
commit 66b098ae36
46 changed files with 4403 additions and 1594 deletions
+446
View File
@@ -0,0 +1,446 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes
} from 'node:crypto'
import type { ChannelMediaAttachment } from '../../shared/channel-contracts'
import { CHANNEL_LIMITS } from '../../shared/channel-contracts'
import {
detectSupportedImage,
mimeTypeFromFileName
} from '../file-media-type'
import { isAllowedWechatUrl } from './wechat-sidecar-security'
const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
const MEDIA_TIMEOUT_MS = 30_000
const MAX_REDIRECTS = 3
const MAX_ENCRYPTED_BYTES =
CHANNEL_LIMITS.maximumAttachmentBytes + 16
type CdnMedia = {
encrypt_query_param?: string
aes_key?: string
full_url?: string
}
export type WechatImageItem = {
media?: CdnMedia
aeskey?: string
mid_size?: number
hd_size?: number
}
export type WechatFileItem = {
media?: CdnMedia
file_name?: string
len?: string
}
export type WechatUploadUrlResponse = {
upload_param?: string
upload_full_url?: string
}
export type WechatOutboundMediaItem =
| {
type: 2
image_item: {
media: {
encrypt_query_param: string
aes_key: string
encrypt_type: 1
}
mid_size: number
}
}
| {
type: 4
file_item: {
media: {
encrypt_query_param: string
aes_key: string
encrypt_type: 1
}
file_name: string
len: string
}
}
function assertAllowedUrl(raw: string): URL {
if (!isAllowedWechatUrl(raw)) {
throw new Error('微信媒体地址不受信任')
}
return new URL(raw)
}
function safeFileName(value: string | undefined, fallback: string): string {
const candidate = [...(value ?? '')]
.map((character) => {
const code = character.codePointAt(0)
return code !== undefined && (code <= 31 || code === 127)
? '_'
: character
})
.join('')
.replace(/[\\/:*?"<>|]/gu, '_')
.trim()
.slice(0, CHANNEL_LIMITS.maximumAttachmentNameLength)
return candidate && candidate !== '.' && candidate !== '..'
? candidate
: fallback
}
function parseAesKey(value: string, encoding: 'hex' | 'base64'): Buffer {
if (value.length > 128) {
throw new Error('微信媒体密钥无效')
}
const decoded = Buffer.from(value, encoding)
if (decoded.byteLength === 16) {
return decoded
}
if (
encoding === 'base64' &&
decoded.byteLength === 32 &&
/^[0-9a-f]{32}$/iu.test(decoded.toString('ascii'))
) {
return Buffer.from(decoded.toString('ascii'), 'hex')
}
throw new Error('微信媒体密钥无效')
}
function resolveDownloadUrl(media: CdnMedia): URL {
if (media.full_url?.trim()) {
return assertAllowedUrl(media.full_url.trim())
}
const parameter = media.encrypt_query_param?.trim()
if (!parameter || parameter.length > 8_192) {
throw new Error('微信媒体下载参数无效')
}
const url = new URL('/c2c/download', `${CDN_BASE_URL}/`)
url.searchParams.set('encrypted_query_param', parameter)
return assertAllowedUrl(url.toString())
}
function withTimeout(
inputSignal: AbortSignal,
timeoutMs = MEDIA_TIMEOUT_MS
): {
signal: AbortSignal
dispose: () => void
} {
const controller = new AbortController()
const abort = (): void => controller.abort(inputSignal.reason)
inputSignal.addEventListener('abort', abort, { once: true })
if (inputSignal.aborted) {
abort()
}
const timeout = setTimeout(
() => controller.abort(new Error('微信媒体传输超时')),
timeoutMs
)
return {
signal: controller.signal,
dispose: () => {
clearTimeout(timeout)
inputSignal.removeEventListener('abort', abort)
}
}
}
async function readBoundedBody(
response: Response,
maximumBytes: number
): Promise<Buffer> {
const declaredLength = Number(response.headers.get('content-length'))
if (
Number.isFinite(declaredLength) &&
declaredLength > maximumBytes
) {
throw new Error('微信媒体超过 12MB 限制')
}
if (!response.body) {
return Buffer.alloc(0)
}
const reader = response.body.getReader()
const chunks: Buffer[] = []
let total = 0
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) {
break
}
total += chunk.value.byteLength
if (total > maximumBytes) {
await reader.cancel()
throw new Error('微信媒体超过 12MB 限制')
}
chunks.push(Buffer.from(chunk.value))
}
} finally {
reader.releaseLock()
}
return Buffer.concat(chunks, total)
}
async function fetchWechatBytes(
initialUrl: URL,
signal: AbortSignal
): Promise<Buffer> {
let url = initialUrl
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
const response = await fetch(url, {
method: 'GET',
redirect: 'manual',
signal
})
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location')
if (!location || redirectCount === MAX_REDIRECTS) {
throw new Error('微信媒体重定向无效')
}
url = assertAllowedUrl(new URL(location, url).toString())
continue
}
if (!response.ok) {
throw new Error(`微信媒体下载失败(${response.status}`)
}
return readBoundedBody(response, MAX_ENCRYPTED_BYTES)
}
throw new Error('微信媒体重定向过多')
}
async function downloadMedia(
media: CdnMedia,
key: Buffer | undefined,
signal: AbortSignal
): Promise<Buffer> {
const timed = withTimeout(signal)
try {
const encrypted = await fetchWechatBytes(
resolveDownloadUrl(media),
timed.signal
)
timed.signal.throwIfAborted()
if (!key) {
if (encrypted.byteLength > CHANNEL_LIMITS.maximumAttachmentBytes) {
throw new Error('微信媒体超过 12MB 限制')
}
return encrypted
}
if (encrypted.byteLength === 0 || encrypted.byteLength % 16 !== 0) {
throw new Error('微信媒体密文无效')
}
const decipher = createDecipheriv('aes-128-ecb', key, null)
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
])
if (
decrypted.byteLength === 0 ||
decrypted.byteLength > CHANNEL_LIMITS.maximumAttachmentBytes
) {
throw new Error('微信媒体超过 12MB 限制')
}
return decrypted
} finally {
timed.dispose()
}
}
export async function downloadWechatImage(
item: WechatImageItem,
fallbackName: string,
signal: AbortSignal
): Promise<ChannelMediaAttachment> {
if (!item.media) {
throw new Error('微信图片缺少媒体引用')
}
const claimedCipherSize = item.hd_size ?? item.mid_size
if (
claimedCipherSize !== undefined &&
(!Number.isSafeInteger(claimedCipherSize) ||
claimedCipherSize < 1 ||
claimedCipherSize > MAX_ENCRYPTED_BYTES)
) {
throw new Error('微信图片超过 12MB 限制')
}
const key = item.aeskey
? parseAesKey(item.aeskey, 'hex')
: item.media.aes_key
? parseAesKey(item.media.aes_key, 'base64')
: undefined
const data = await downloadMedia(item.media, key, signal)
const format = detectSupportedImage(data)
return {
name: safeFileName(
`${fallbackName}.${format.extension}`,
`微信图片.${format.extension}`
),
mimeType: format.mimeType,
size: data.byteLength,
kind: 'image',
dataBase64: data.toString('base64')
}
}
export async function downloadWechatFile(
item: WechatFileItem,
signal: AbortSignal
): Promise<ChannelMediaAttachment> {
if (!item.media?.aes_key) {
throw new Error('微信文件缺少加密信息')
}
const claimedSize = Number(item.len)
if (
item.len !== undefined &&
(!Number.isSafeInteger(claimedSize) ||
claimedSize < 1 ||
claimedSize > CHANNEL_LIMITS.maximumAttachmentBytes)
) {
throw new Error('微信文件超过 12MB 限制')
}
const data = await downloadMedia(
item.media,
parseAesKey(item.media.aes_key, 'base64'),
signal
)
if (item.len !== undefined && data.byteLength !== claimedSize) {
throw new Error('微信文件大小校验失败')
}
const name = safeFileName(item.file_name, '微信文件.bin')
return {
name,
mimeType: mimeTypeFromFileName(name),
size: data.byteLength,
kind: 'file',
dataBase64: data.toString('base64')
}
}
function encryptedSize(plaintextSize: number): number {
return Math.ceil((plaintextSize + 1) / 16) * 16
}
async function uploadWechatBytes(
url: URL,
body: Buffer,
signal: AbortSignal
): Promise<string> {
const timed = withTimeout(signal)
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body,
redirect: 'manual',
signal: timed.signal
})
if (response.status >= 300 && response.status < 400) {
throw new Error('微信媒体上传重定向无效')
}
if (!response.ok) {
throw new Error(`微信媒体上传失败(${response.status}`)
}
const parameter = response.headers.get('x-encrypted-param')?.trim()
if (!parameter || parameter.length > 8_192) {
throw new Error('微信媒体上传结果无效')
}
return parameter
} finally {
timed.dispose()
}
}
export async function uploadWechatAttachment(input: {
attachment: ChannelMediaAttachment
recipientId: string
signal: AbortSignal
getUploadUrl: (request: {
filekey: string
media_type: 1 | 3
to_user_id: string
rawsize: number
rawfilemd5: string
filesize: number
no_need_thumb: true
aeskey: string
}) => Promise<WechatUploadUrlResponse>
}): Promise<WechatOutboundMediaItem> {
const attachment = input.attachment
const plaintext = Buffer.from(attachment.dataBase64, 'base64')
if (
plaintext.byteLength !== attachment.size ||
plaintext.byteLength === 0 ||
plaintext.byteLength > CHANNEL_LIMITS.maximumAttachmentBytes
) {
throw new Error('待发送附件大小无效')
}
if (attachment.kind === 'image') {
detectSupportedImage(plaintext)
}
const filekey = randomBytes(16).toString('hex')
const key = randomBytes(16)
const response = await input.getUploadUrl({
filekey,
media_type: attachment.kind === 'image' ? 1 : 3,
to_user_id: input.recipientId,
rawsize: plaintext.byteLength,
rawfilemd5: createHash('md5').update(plaintext).digest('hex'),
filesize: encryptedSize(plaintext.byteLength),
no_need_thumb: true,
aeskey: key.toString('hex')
})
const fullUrl = response.upload_full_url?.trim()
const uploadParameter = response.upload_param?.trim()
const uploadUrl = fullUrl
? assertAllowedUrl(fullUrl)
: (() => {
if (!uploadParameter || uploadParameter.length > 8_192) {
throw new Error('微信媒体上传地址缺失')
}
const url = new URL('/c2c/upload', `${CDN_BASE_URL}/`)
url.searchParams.set('encrypted_query_param', uploadParameter)
url.searchParams.set('filekey', filekey)
return assertAllowedUrl(url.toString())
})()
const cipher = createCipheriv('aes-128-ecb', key, null)
const encrypted = Buffer.concat([
cipher.update(plaintext),
cipher.final()
])
const downloadParameter = await uploadWechatBytes(
uploadUrl,
encrypted,
input.signal
)
const aesKey = Buffer.from(key.toString('hex'), 'ascii').toString(
'base64'
)
if (attachment.kind === 'image') {
return {
type: 2,
image_item: {
media: {
encrypt_query_param: downloadParameter,
aes_key: aesKey,
encrypt_type: 1
},
mid_size: encrypted.byteLength
}
}
}
return {
type: 4,
file_item: {
media: {
encrypt_query_param: downloadParameter,
aes_key: aesKey,
encrypt_type: 1
},
file_name: safeFileName(attachment.name, 'GoodBuddy 文件.bin'),
len: String(plaintext.byteLength)
}
}
}