Compare commits

...

2 Commits

Author SHA1 Message Date
Renato Alcara acfaaff68b 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>
2026-03-04 10:15:32 -03:00
Renato Alcara 4fa7d4ee11 fix(sticker): align sticker pack with WABA Android behavior (Frida capture)
Align sticker pack implementation with WhatsApp Business Android (WABA)
behavior captured via Frida reverse engineering. Fixes 4 critical issues
identified in field-by-field comparison with WABA's sticker DB schema.

- Add Lottie/WAS format detection (isLottieBuffer) for animated stickers
- Fix mimetype: use 'application/was' for Lottie, 'image/webp' for WebP
- Fix isLottie: auto-detect instead of hardcoded false
- Fix tray icon: convert cover to PNG format (WABA uses .png, not .webp)
- Add 512x512 dimension enforcement for WebP stickers (WABA standard)
- Add isLottie field to Sticker type for explicit format override

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:45:55 -03:00
2 changed files with 123 additions and 20 deletions
+3 -1
View File
@@ -54,12 +54,14 @@ export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL
* Individual sticker in a sticker pack * Individual sticker in a sticker pack
*/ */
export type Sticker = { export type Sticker = {
/** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */ /** Buffer, Stream or URL of the sticker image (WebP or Lottie/WAS format) */
data: WAMediaUpload data: WAMediaUpload
/** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */ /** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */
emojis?: string[] emojis?: string[]
/** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */ /** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */
accessibilityLabel?: string accessibilityLabel?: string
/** Force Lottie format detection (auto-detected if omitted) */
isLottie?: boolean
} }
/** /**
+120 -19
View File
@@ -2,6 +2,7 @@ import { Boom } from '@hapi/boom'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { zipSync } from 'fflate' import { zipSync } from 'fflate'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import { gzipSync, gunzipSync } from 'zlib'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import type { MediaType } from '../Defaults/index.js' import type { MediaType } from '../Defaults/index.js'
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js' import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
@@ -101,6 +102,49 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
return false return false
} }
/**
* Detecta se um buffer é Lottie JSON (raw ou gzip-compressed/WAS)
*
* WABA usa mimetype `application/was` para stickers Lottie.
* WAS (WhatsApp Animated Sticker) = gzip-compressed Lottie JSON.
*
* Detecção:
* - Gzip (0x1f 0x8b): descomprime e verifica se é Lottie JSON
* - JSON bruto: verifica campos Lottie obrigatórios (v, ip, op, layers)
*
* @param buffer - Buffer to check
* @returns true if buffer is Lottie/WAS format
*/
export const isLottieBuffer = (buffer: Buffer): boolean => {
if (buffer.length < 2) return false
let jsonBuffer: Buffer
// Check if gzip-compressed (WAS format)
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
try {
// SECURITY: Limit decompressed output to 50MB to prevent decompression bombs
jsonBuffer = gunzipSync(buffer, { maxOutputLength: 50 * 1024 * 1024 }) as Buffer
} catch {
return false
}
} else if (buffer[0] === 0x7b) {
// Starts with '{' - could be raw Lottie JSON
jsonBuffer = buffer
} else {
return false
}
// 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 ALL required Lottie fields
return str.includes('"v"') && str.includes('"layers"') && str.includes('"ip"') && str.includes('"op"')
} catch {
return false
}
}
/** /**
* Converte uma imagem para WebP usando Sharp * Converte uma imagem para WebP usando Sharp
* Preserva o buffer original se já for WebP para manter EXIF e animações * Preserva o buffer original se já for WebP para manter EXIF e animações
@@ -114,12 +158,25 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
const convertToWebP = async ( const convertToWebP = async (
buffer: Buffer, buffer: Buffer,
logger?: ILogger logger?: ILogger
): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => { ): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => {
// Lottie/WAS: ensure gzip-compressed format (WAS = gzip Lottie JSON)
if (isLottieBuffer(buffer)) {
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) // Se já é WebP, preserva o buffer original (mantém EXIF e animações)
if (isWebPBuffer(buffer)) { if (isWebPBuffer(buffer)) {
const isAnimated = isAnimatedWebP(buffer) const isAnimated = isAnimatedWebP(buffer)
logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer') logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer')
return { webpBuffer: buffer, isAnimated } return { webpBuffer: buffer, isAnimated, isLottie: false }
} }
// Tenta usar Sharp para converter // Tenta usar Sharp para converter
@@ -135,7 +192,7 @@ const convertToWebP = async (
logger?.trace('Converting image to WebP using Sharp') logger?.trace('Converting image to WebP using Sharp')
const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer() const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
return { webpBuffer, isAnimated: false } return { webpBuffer, isAnimated: false, isLottie: false }
} }
/** /**
@@ -338,9 +395,11 @@ export type PrepareStickerPackMessageOptions = {
* *
* **Especificações WhatsApp:** * **Especificações WhatsApp:**
* - 3-30 stickers por pack (oficial) * - 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 * - 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 stickerPack - Sticker pack data with stickers, cover, name, publisher
* @param options - Upload function and optional logger * @param options - Upload function and optional logger
@@ -464,9 +523,36 @@ export const prepareStickerPackMessage = async (
// Obtém buffer do sticker // Obtém buffer do sticker
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`) const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
// Converte para WebP // Detecta formato e converte se necessário
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger) let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger)
// 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)
if (!isLottie && isWebPBuffer(webpBuffer)) {
const lib = await getImageProcessingLibrary()
if (lib?.sharp) {
const metadata = await lib.sharp.default(webpBuffer).metadata()
if (metadata.width !== 512 || metadata.height !== 512) {
logger?.trace(
{ index: i, width: metadata.width, height: metadata.height },
'Resizing sticker to 512x512 (WABA standard)'
)
webpBuffer = await lib.sharp
.default(webpBuffer)
.resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.webp()
.toBuffer()
}
}
}
// ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50) // ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
@@ -483,7 +569,7 @@ export const prepareStickerPackMessage = async (
if (lib?.sharp) { if (lib?.sharp) {
// Try quality 70 // Try quality 70
try { 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 // eslint-disable-next-line max-depth
if (compressed70.length <= MAX_STICKER_SIZE) { if (compressed70.length <= MAX_STICKER_SIZE) {
@@ -498,7 +584,7 @@ export const prepareStickerPackMessage = async (
) )
} else { } else {
// Try quality 50 // 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 // eslint-disable-next-line max-depth
if (compressed50.length <= MAX_STICKER_SIZE) { if (compressed50.length <= MAX_STICKER_SIZE) {
@@ -547,12 +633,13 @@ export const prepareStickerPackMessage = async (
) )
} }
// Gera nome do arquivo: hash.webp (deduplicação automática) // Gera nome do arquivo: hash.webp ou hash.was (WABA: plain_file_hash.ext)
const sha256Hash = generateSha256Hash(webpBuffer) const sha256Hash = generateSha256Hash(webpBuffer)
const fileName = `${sha256Hash}.webp` const extension = isLottie ? 'was' : 'webp'
const fileName = `${sha256Hash}.${extension}`
logger?.trace( logger?.trace(
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated }, { index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie },
'Sticker processed successfully' 'Sticker processed successfully'
) )
@@ -560,6 +647,7 @@ export const prepareStickerPackMessage = async (
fileName, fileName,
webpBuffer, webpBuffer,
isAnimated, isAnimated,
isLottie,
emojis: sticker.emojis || [], emojis: sticker.emojis || [],
accessibilityLabel: sticker.accessibilityLabel accessibilityLabel: sticker.accessibilityLabel
} }
@@ -578,7 +666,7 @@ export const prepareStickerPackMessage = async (
for (const result of processedStickers) { for (const result of processedStickers) {
if (!result) continue if (!result) continue
const { fileName, webpBuffer, isAnimated, emojis, accessibilityLabel } = result const { fileName, webpBuffer, isAnimated, isLottie, emojis, accessibilityLabel } = result
// SECURITY FIX #7: Check if this hash already exists (duplicate sticker) // SECURITY FIX #7: Check if this hash already exists (duplicate sticker)
const existingMetadata = metadataByHash.get(fileName) const existingMetadata = metadataByHash.get(fileName)
@@ -605,13 +693,14 @@ export const prepareStickerPackMessage = async (
// New sticker - add to ZIP and create metadata // New sticker - add to ZIP and create metadata
stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }] stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }]
// WABA: mimetype é 'application/was' para Lottie, 'image/webp' para WebP
const metadata: proto.Message.StickerPackMessage.ISticker = { const metadata: proto.Message.StickerPackMessage.ISticker = {
fileName, fileName,
isAnimated, isAnimated,
emojis, emojis,
accessibilityLabel, accessibilityLabel,
isLottie: false, isLottie,
mimetype: 'image/webp' mimetype: isLottie ? 'application/was' : 'image/webp'
} }
metadataByHash.set(fileName, metadata) metadataByHash.set(fileName, metadata)
@@ -636,10 +725,22 @@ export const prepareStickerPackMessage = async (
logger?.trace('Processing cover image') logger?.trace('Processing cover image')
coverBuffer = await mediaToBuffer(cover, 'cover image') coverBuffer = await mediaToBuffer(cover, 'cover image')
// Converte cover para WebP e adiciona ao ZIP // Tray icon uses PNG format, 96x96 pixels (official client standard)
const result = await convertToWebP(coverBuffer, logger) const lib = await getImageProcessingLibrary()
coverWebP = result.webpBuffer if (!lib?.sharp) {
coverFileName = `${stickerPackId}.webp` 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 }] stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
} catch (error) { } catch (error) {
throw new Boom(`Failed to process cover image: ${(error as Error).message}`, { throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {