From 2b95f446ddabc12d02074148bcee82894c944b1f Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 18 Mar 2026 07:34:18 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20Security=20update=20Camera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add file size limits (2MB photos, 1MB thumbnails) with pre-read rejection - Add PNG dimension validation via IHDR header before full decode (prevents decompression bombs) - Double-check decoded dimensions against max 1024x1024 (photos) / 640x640 (thumbnails) - Increase render cooldown from 5s to 15s - Add daily render quota (50/day per user) with 24h rolling window - Fix cooldown message to show remaining seconds correctly - Add structured logging for all rejected uploads - Replace e.printStackTrace() with proper SLF4J logging --- .../camera/CameraRoomPictureEvent.java | 119 +++++++++++++++++- .../camera/CameraRoomThumbnailEvent.java | 87 ++++++++++++- 2 files changed, 195 insertions(+), 11 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/camera/CameraRoomPictureEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/camera/CameraRoomPictureEvent.java index 0dbb60be..af8b4d0e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/camera/CameraRoomPictureEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/camera/CameraRoomPictureEvent.java @@ -10,15 +10,26 @@ import com.eu.habbo.messages.outgoing.camera.CameraURLComposer; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; import io.netty.buffer.ByteBufUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.util.Iterator; public class CameraRoomPictureEvent extends MessageHandler { - public static int CAMERA_RENDER_DELAY = 5; + private static final Logger LOGGER = LoggerFactory.getLogger(CameraRoomPictureEvent.class); + + public static int CAMERA_RENDER_DELAY = 15; + public static int MAX_IMAGE_BYTES = 2 * 1024 * 1024; // 2 MB max upload + public static int MAX_IMAGE_WIDTH = 1024; + public static int MAX_IMAGE_HEIGHT = 1024; + public static int MAX_DAILY_RENDERS = 50; private ByteBuf image = null; @@ -47,7 +58,7 @@ public class CameraRoomPictureEvent extends MessageHandler { if (habboStats.cache.containsKey("camera_render_cooldown")) { int cameraTimestamp = (Integer) habboStats.cache.get("camera_render_cooldown"); if (timestamp - cameraTimestamp < CAMERA_RENDER_DELAY) { - String alertMessage = Emulator.getTexts().getValue("camera.wait").replace("%seconds%", Integer.toString(timestamp - cameraTimestamp)); + String alertMessage = Emulator.getTexts().getValue("camera.wait").replace("%seconds%", Integer.toString(CAMERA_RENDER_DELAY - (timestamp - cameraTimestamp))); habbo.alert(alertMessage); if (habboInfo.getPhotoURL() != null) { String[] splittedPhotoURL = habboInfo.getPhotoURL().split("/"); @@ -59,16 +70,55 @@ public class CameraRoomPictureEvent extends MessageHandler { } } + // Daily render quota check + int dailyRenderCount = getDailyRenderCount(habboStats, timestamp); + if (dailyRenderCount >= MAX_DAILY_RENDERS) { + habbo.alert(Emulator.getTexts().getValue("camera.daily.limit", "You have reached the daily photo limit. Try again tomorrow.")); + return; + } + incrementDailyRenderCount(habboStats, timestamp, dailyRenderCount); + habboStats.cache.put("camera_render_cooldown", timestamp); Room room = habboInfo.getCurrentRoom(); if (room == null) return; int count = this.packet.readInt(); + + // Reject oversized payloads before reading + if (count <= 0 || count > MAX_IMAGE_BYTES) { + LOGGER.warn("User {} attempted camera upload with invalid size: {} bytes", habboInfo.getUsername(), count); + habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); + return; + } + this.image = this.packet.getBuffer().readBytes(count); if (this.image == null) return; byte[] imageBytes = ByteBufUtil.getBytes(this.image, 0, 4, true); - if (imageBytes == null || imageBytes.length < 4 || !isPNG(imageBytes)) return; + if (imageBytes == null || imageBytes.length < 4 || !isPNG(imageBytes)) { + LOGGER.warn("User {} attempted camera upload with non-PNG data", habboInfo.getUsername()); + return; + } + + // Validate image dimensions before fully decoding + int[] dimensions; + try { + dimensions = readPNGDimensions(this.image); + } catch (IOException e) { + LOGGER.warn("User {} uploaded image with unreadable dimensions", habboInfo.getUsername()); + handleImageProcessingError(habbo); + return; + } + + if (dimensions == null || dimensions[0] <= 0 || dimensions[1] <= 0 + || dimensions[0] > MAX_IMAGE_WIDTH || dimensions[1] > MAX_IMAGE_HEIGHT) { + LOGGER.warn("User {} attempted camera upload with invalid dimensions: {}x{}", + habboInfo.getUsername(), + dimensions != null ? dimensions[0] : "null", + dimensions != null ? dimensions[1] : "null"); + habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); + return; + } BufferedImage theImage; try (ByteBufInputStream in = new ByteBufInputStream(this.image)) { @@ -78,6 +128,19 @@ public class CameraRoomPictureEvent extends MessageHandler { return; } + if (theImage == null) { + LOGGER.warn("User {} uploaded image that could not be decoded", habboInfo.getUsername()); + handleImageProcessingError(habbo); + return; + } + + // Double-check decoded dimensions match expectations + if (theImage.getWidth() > MAX_IMAGE_WIDTH || theImage.getHeight() > MAX_IMAGE_HEIGHT) { + LOGGER.warn("User {} decoded image exceeds dimension limits: {}x{}", habboInfo.getUsername(), theImage.getWidth(), theImage.getHeight()); + handleImageProcessingError(habbo); + return; + } + String fileName = habboInfo.getId() + "_" + timestamp; String URL = fileName + ".png"; String URLsmall = fileName + "_small.png"; @@ -96,10 +159,12 @@ public class CameraRoomPictureEvent extends MessageHandler { try { ImageIO.write(theImage, "png", imageFile); - Image smallImage = theImage.getScaledInstance(theImage.getWidth(null) / 2, theImage.getHeight(null) / 2, Image.SCALE_SMOOTH); - BufferedImage bi = new BufferedImage(smallImage.getWidth(null), smallImage.getHeight(null), BufferedImage.TYPE_INT_ARGB); + int smallWidth = theImage.getWidth(null) / 2; + int smallHeight = theImage.getHeight(null) / 2; + BufferedImage bi = new BufferedImage(smallWidth, smallHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D graphics2D = bi.createGraphics(); - graphics2D.drawImage(smallImage, 0, 0, null); + graphics2D.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION, java.awt.RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + graphics2D.drawImage(theImage, 0, 0, smallWidth, smallHeight, null); graphics2D.dispose(); ImageIO.write(bi, "png", smallImageFile); } catch (IOException e) { @@ -114,6 +179,48 @@ public class CameraRoomPictureEvent extends MessageHandler { return bytes[0] == (byte) 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47; } + /** + * Read PNG dimensions from the IHDR chunk without fully decoding the image. + * This prevents decompression bomb attacks by checking dimensions before allocation. + */ + private int[] readPNGDimensions(ByteBuf buf) throws IOException { + try (ByteBufInputStream in = new ByteBufInputStream(buf.duplicate())) { + try (ImageInputStream iis = ImageIO.createImageInputStream(in)) { + Iterator readers = ImageIO.getImageReaders(iis); + if (!readers.hasNext()) return null; + + ImageReader reader = readers.next(); + try { + reader.setInput(iis); + int width = reader.getWidth(0); + int height = reader.getHeight(0); + return new int[]{width, height}; + } finally { + reader.dispose(); + } + } + } + } + + private int getDailyRenderCount(HabboStats stats, int currentTimestamp) { + if (!stats.cache.containsKey("camera_daily_count") || !stats.cache.containsKey("camera_daily_reset")) { + return 0; + } + int resetTimestamp = (Integer) stats.cache.get("camera_daily_reset"); + // Reset counter if more than 24 hours have passed + if (currentTimestamp - resetTimestamp >= 86400) { + return 0; + } + return (Integer) stats.cache.get("camera_daily_count"); + } + + private void incrementDailyRenderCount(HabboStats stats, int currentTimestamp, int currentCount) { + if (currentCount == 0) { + stats.cache.put("camera_daily_reset", currentTimestamp); + } + stats.cache.put("camera_daily_count", currentCount + 1); + } + private void handleImageProcessingError(Habbo habbo) { habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/camera/CameraRoomThumbnailEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/camera/CameraRoomThumbnailEvent.java index c2adc118..ba630619 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/camera/CameraRoomThumbnailEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/camera/CameraRoomThumbnailEvent.java @@ -10,14 +10,24 @@ import com.eu.habbo.messages.outgoing.camera.CameraRoomThumbnailSavedComposer; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; import io.netty.buffer.ByteBufUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.util.Iterator; public class CameraRoomThumbnailEvent extends MessageHandler { - public static int CAMERA_RENDER_DELAY = 5; + private static final Logger LOGGER = LoggerFactory.getLogger(CameraRoomThumbnailEvent.class); + + public static int CAMERA_RENDER_DELAY = 15; + public static int MAX_THUMBNAIL_BYTES = 1024 * 1024; // 1 MB max for thumbnails + public static int MAX_THUMBNAIL_WIDTH = 640; + public static int MAX_THUMBNAIL_HEIGHT = 640; private ByteBuf image = null; @@ -45,7 +55,7 @@ public class CameraRoomThumbnailEvent extends MessageHandler { if (habboStats.cache.containsKey("camera_render_cooldown")) { int cameraTimestamp = (Integer) habboStats.cache.get("camera_render_cooldown"); if (timestamp - cameraTimestamp < CAMERA_RENDER_DELAY) { - String alertMessage = Emulator.getTexts().getValue("camera.wait").replace("%seconds%", Integer.toString(timestamp - cameraTimestamp)); + String alertMessage = Emulator.getTexts().getValue("camera.wait").replace("%seconds%", Integer.toString(CAMERA_RENDER_DELAY - (timestamp - cameraTimestamp))); habbo.alert(alertMessage); return; } @@ -57,14 +67,58 @@ public class CameraRoomThumbnailEvent extends MessageHandler { if (room == null || !room.isOwner(habbo)) return; int count = this.packet.readInt(); + + // Reject oversized payloads before reading + if (count <= 0 || count > MAX_THUMBNAIL_BYTES) { + LOGGER.warn("User {} attempted thumbnail upload with invalid size: {} bytes", habboInfo.getUsername(), count); + habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); + return; + } + this.image = this.packet.getBuffer().readBytes(count); - if (this.image == null || !isValidImage(this.image)) return; + if (this.image == null || !isValidImage(this.image)) { + LOGGER.warn("User {} attempted thumbnail upload with non-PNG data", habboInfo.getUsername()); + return; + } + + // Validate dimensions before fully decoding (prevents decompression bombs) + int[] dimensions; + try { + dimensions = readPNGDimensions(this.image); + } catch (IOException e) { + LOGGER.warn("User {} uploaded thumbnail with unreadable dimensions", habboInfo.getUsername()); + habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); + return; + } + + if (dimensions == null || dimensions[0] <= 0 || dimensions[1] <= 0 + || dimensions[0] > MAX_THUMBNAIL_WIDTH || dimensions[1] > MAX_THUMBNAIL_HEIGHT) { + LOGGER.warn("User {} attempted thumbnail upload with invalid dimensions: {}x{}", + habboInfo.getUsername(), + dimensions != null ? dimensions[0] : "null", + dimensions != null ? dimensions[1] : "null"); + habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); + return; + } BufferedImage theImage; try (ByteBufInputStream in = new ByteBufInputStream(this.image)) { theImage = ImageIO.read(in); } catch (IOException e) { - e.printStackTrace(); + LOGGER.error("Failed to decode thumbnail from user {}", habboInfo.getUsername(), e); + habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); + return; + } + + if (theImage == null) { + LOGGER.warn("User {} uploaded thumbnail that could not be decoded", habboInfo.getUsername()); + habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); + return; + } + + // Double-check decoded dimensions + if (theImage.getWidth() > MAX_THUMBNAIL_WIDTH || theImage.getHeight() > MAX_THUMBNAIL_HEIGHT) { + LOGGER.warn("User {} decoded thumbnail exceeds dimension limits: {}x{}", habboInfo.getUsername(), theImage.getWidth(), theImage.getHeight()); habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); return; } @@ -73,7 +127,7 @@ public class CameraRoomThumbnailEvent extends MessageHandler { try { ImageIO.write(theImage, "png", imageFile); } catch (IOException e) { - e.printStackTrace(); + LOGGER.error("Failed to write thumbnail for room {}", room.getId(), e); habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); return; } @@ -87,4 +141,27 @@ public class CameraRoomThumbnailEvent extends MessageHandler { && imageBytes[0] == (byte) 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47; } + + /** + * Read PNG dimensions from the IHDR chunk without fully decoding the image. + * This prevents decompression bomb attacks by checking dimensions before allocation. + */ + private int[] readPNGDimensions(ByteBuf buf) throws IOException { + try (ByteBufInputStream in = new ByteBufInputStream(buf.duplicate())) { + try (ImageInputStream iis = ImageIO.createImageInputStream(in)) { + Iterator readers = ImageIO.getImageReaders(iis); + if (!readers.hasNext()) return null; + + ImageReader reader = readers.next(); + try { + reader.setInput(iis); + int width = reader.getWidth(0); + int height = reader.getHeight(0); + return new int[]{width, height}; + } finally { + reader.dispose(); + } + } + } + } }