fix(sticker): address PR review — 8 fixes for security and correctness
- Gzip raw Lottie JSON to valid WAS format before emitting as .was - Add decompression bomb protection (maxOutputLength: 50MB) in gunzipSync - Fix Lottie validation: require both ip AND op fields (was OR) - Validate isLottie override: throw on mismatch with detected format - Fix compression to use resized webpBuffer (preserves 512x512 enforcement) - Require Sharp for cover processing (remove broken WebP-in-PNG fallback) - Update docs: tray icon is 96x96 PNG, thumbnail is 252x252 JPEG Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+40
-27
@@ -2,7 +2,7 @@ import { Boom } from '@hapi/boom'
|
||||
import { createHash } from 'crypto'
|
||||
import { zipSync } from 'fflate'
|
||||
import { promises as fs } from 'fs'
|
||||
import { gunzipSync } from 'zlib'
|
||||
import { gzipSync, gunzipSync } from 'zlib'
|
||||
import { proto } from '../../WAProto/index.js'
|
||||
import type { MediaType } from '../Defaults/index.js'
|
||||
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
|
||||
@@ -123,7 +123,8 @@ export const isLottieBuffer = (buffer: Buffer): boolean => {
|
||||
// Check if gzip-compressed (WAS format)
|
||||
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
||||
try {
|
||||
jsonBuffer = gunzipSync(buffer) as Buffer
|
||||
// SECURITY: Limit decompressed output to 50MB to prevent decompression bombs
|
||||
jsonBuffer = gunzipSync(buffer, { maxOutputLength: 50 * 1024 * 1024 }) as Buffer
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -134,11 +135,11 @@ export const isLottieBuffer = (buffer: Buffer): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate Lottie JSON structure: must have v, ip, op, layers
|
||||
// Validate Lottie JSON structure: must have v, ip, op, AND layers
|
||||
try {
|
||||
const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096))
|
||||
// Quick check for required Lottie fields
|
||||
return str.includes('"v"') && str.includes('"layers"') && (str.includes('"ip"') || str.includes('"op"'))
|
||||
// Quick check for ALL required Lottie fields
|
||||
return str.includes('"v"') && str.includes('"layers"') && str.includes('"ip"') && str.includes('"op"')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -158,10 +159,17 @@ const convertToWebP = async (
|
||||
buffer: Buffer,
|
||||
logger?: ILogger
|
||||
): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => {
|
||||
// Lottie/WAS: passthrough sem converter (formato vetorial, mimetype=application/was)
|
||||
// Lottie/WAS: ensure gzip-compressed format (WAS = gzip Lottie JSON)
|
||||
if (isLottieBuffer(buffer)) {
|
||||
logger?.trace('Input is Lottie/WAS format, preserving original buffer')
|
||||
return { webpBuffer: buffer, isAnimated: true, isLottie: true }
|
||||
let wasBuffer = buffer
|
||||
// Raw Lottie JSON (starts with '{') must be gzipped to produce valid WAS
|
||||
if (buffer[0] === 0x7b) {
|
||||
logger?.trace('Raw Lottie JSON detected, gzip-compressing to WAS format')
|
||||
wasBuffer = gzipSync(buffer) as Buffer
|
||||
}
|
||||
|
||||
logger?.trace('Input is Lottie/WAS format')
|
||||
return { webpBuffer: wasBuffer, isAnimated: true, isLottie: true }
|
||||
}
|
||||
|
||||
// Se já é WebP, preserva o buffer original (mantém EXIF e animações)
|
||||
@@ -387,9 +395,11 @@ export type PrepareStickerPackMessageOptions = {
|
||||
*
|
||||
* **Especificações WhatsApp:**
|
||||
* - 3-30 stickers por pack (oficial)
|
||||
* - WebP obrigatório
|
||||
* - WebP ou Lottie/WAS (application/was)
|
||||
* - Stickers: 512x512 pixels (auto-resize)
|
||||
* - Recomendado: 100KB por sticker estático, 500KB animado
|
||||
* - Tray icon: 252x252 pixels
|
||||
* - Tray icon: 96x96 pixels (PNG no ZIP)
|
||||
* - Thumbnail: 252x252 pixels (JPEG, upload separado)
|
||||
*
|
||||
* @param stickerPack - Sticker pack data with stickers, cover, name, publisher
|
||||
* @param options - Upload function and optional logger
|
||||
@@ -517,9 +527,12 @@ export const prepareStickerPackMessage = async (
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger)
|
||||
|
||||
// Honor explicit isLottie flag from user input
|
||||
if (sticker.isLottie !== undefined) {
|
||||
isLottie = sticker.isLottie
|
||||
// Validate explicit isLottie flag — must match detected format
|
||||
if (sticker.isLottie !== undefined && sticker.isLottie !== isLottie) {
|
||||
throw new Boom(
|
||||
`Sticker ${i + 1}: explicit isLottie=${sticker.isLottie} does not match detected format (detected=${isLottie})`,
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// WABA: Enforce 512x512 dimensions for WebP stickers (Lottie is vector, skip)
|
||||
@@ -556,7 +569,7 @@ export const prepareStickerPackMessage = async (
|
||||
if (lib?.sharp) {
|
||||
// Try quality 70
|
||||
try {
|
||||
const compressed70 = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer()
|
||||
const compressed70 = await lib.sharp.default(webpBuffer).webp({ quality: 70 }).toBuffer()
|
||||
|
||||
// eslint-disable-next-line max-depth
|
||||
if (compressed70.length <= MAX_STICKER_SIZE) {
|
||||
@@ -571,7 +584,7 @@ export const prepareStickerPackMessage = async (
|
||||
)
|
||||
} else {
|
||||
// Try quality 50
|
||||
const compressed50 = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer()
|
||||
const compressed50 = await lib.sharp.default(webpBuffer).webp({ quality: 50 }).toBuffer()
|
||||
|
||||
// eslint-disable-next-line max-depth
|
||||
if (compressed50.length <= MAX_STICKER_SIZE) {
|
||||
@@ -712,21 +725,21 @@ export const prepareStickerPackMessage = async (
|
||||
logger?.trace('Processing cover image')
|
||||
coverBuffer = await mediaToBuffer(cover, 'cover image')
|
||||
|
||||
// WABA: Tray icon uses PNG format (installed_tray_image_id = .png)
|
||||
// Tray icon uses PNG format, 96x96 pixels (official client standard)
|
||||
const lib = await getImageProcessingLibrary()
|
||||
if (lib?.sharp) {
|
||||
// Convert cover to PNG and resize to 96x96 (WABA tray icon standard)
|
||||
coverWebP = await lib.sharp
|
||||
.default(coverBuffer)
|
||||
.resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer()
|
||||
} else {
|
||||
// Fallback: convert to WebP if Sharp not available
|
||||
const result = await convertToWebP(coverBuffer, logger)
|
||||
coverWebP = result.webpBuffer
|
||||
if (!lib?.sharp) {
|
||||
throw new Boom(
|
||||
'Sharp library is required for cover/tray icon processing. Install with: yarn add sharp',
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
coverWebP = await lib.sharp
|
||||
.default(coverBuffer)
|
||||
.resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
coverFileName = `${stickerPackId}.png`
|
||||
stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user