🆙 Security update Camera

- 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
This commit is contained in:
duckietm
2026-03-18 07:34:18 +01:00
parent a056bc4b79
commit 2b95f446dd
2 changed files with 195 additions and 11 deletions
@@ -10,15 +10,26 @@ import com.eu.habbo.messages.outgoing.camera.CameraURLComposer;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufInputStream; import io.netty.buffer.ByteBufInputStream;
import io.netty.buffer.ByteBufUtil; import io.netty.buffer.ByteBufUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.*; import java.awt.*;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.Iterator;
public class CameraRoomPictureEvent extends MessageHandler { 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; private ByteBuf image = null;
@@ -47,7 +58,7 @@ public class CameraRoomPictureEvent extends MessageHandler {
if (habboStats.cache.containsKey("camera_render_cooldown")) { if (habboStats.cache.containsKey("camera_render_cooldown")) {
int cameraTimestamp = (Integer) habboStats.cache.get("camera_render_cooldown"); int cameraTimestamp = (Integer) habboStats.cache.get("camera_render_cooldown");
if (timestamp - cameraTimestamp < CAMERA_RENDER_DELAY) { 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); habbo.alert(alertMessage);
if (habboInfo.getPhotoURL() != null) { if (habboInfo.getPhotoURL() != null) {
String[] splittedPhotoURL = habboInfo.getPhotoURL().split("/"); 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); habboStats.cache.put("camera_render_cooldown", timestamp);
Room room = habboInfo.getCurrentRoom(); Room room = habboInfo.getCurrentRoom();
if (room == null) return; if (room == null) return;
int count = this.packet.readInt(); 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); this.image = this.packet.getBuffer().readBytes(count);
if (this.image == null) return; if (this.image == null) return;
byte[] imageBytes = ByteBufUtil.getBytes(this.image, 0, 4, true); 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; BufferedImage theImage;
try (ByteBufInputStream in = new ByteBufInputStream(this.image)) { try (ByteBufInputStream in = new ByteBufInputStream(this.image)) {
@@ -78,6 +128,19 @@ public class CameraRoomPictureEvent extends MessageHandler {
return; 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 fileName = habboInfo.getId() + "_" + timestamp;
String URL = fileName + ".png"; String URL = fileName + ".png";
String URLsmall = fileName + "_small.png"; String URLsmall = fileName + "_small.png";
@@ -96,10 +159,12 @@ public class CameraRoomPictureEvent extends MessageHandler {
try { try {
ImageIO.write(theImage, "png", imageFile); ImageIO.write(theImage, "png", imageFile);
Image smallImage = theImage.getScaledInstance(theImage.getWidth(null) / 2, theImage.getHeight(null) / 2, Image.SCALE_SMOOTH); int smallWidth = theImage.getWidth(null) / 2;
BufferedImage bi = new BufferedImage(smallImage.getWidth(null), smallImage.getHeight(null), BufferedImage.TYPE_INT_ARGB); int smallHeight = theImage.getHeight(null) / 2;
BufferedImage bi = new BufferedImage(smallWidth, smallHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics2D = bi.createGraphics(); 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(); graphics2D.dispose();
ImageIO.write(bi, "png", smallImageFile); ImageIO.write(bi, "png", smallImageFile);
} catch (IOException e) { } 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; 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<ImageReader> 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) { private void handleImageProcessingError(Habbo habbo) {
habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); habbo.alert(Emulator.getTexts().getValue("camera.error.creation"));
} }
@@ -10,14 +10,24 @@ import com.eu.habbo.messages.outgoing.camera.CameraRoomThumbnailSavedComposer;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufInputStream; import io.netty.buffer.ByteBufInputStream;
import io.netty.buffer.ByteBufUtil; import io.netty.buffer.ByteBufUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.Iterator;
public class CameraRoomThumbnailEvent extends MessageHandler { 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; private ByteBuf image = null;
@@ -45,7 +55,7 @@ public class CameraRoomThumbnailEvent extends MessageHandler {
if (habboStats.cache.containsKey("camera_render_cooldown")) { if (habboStats.cache.containsKey("camera_render_cooldown")) {
int cameraTimestamp = (Integer) habboStats.cache.get("camera_render_cooldown"); int cameraTimestamp = (Integer) habboStats.cache.get("camera_render_cooldown");
if (timestamp - cameraTimestamp < CAMERA_RENDER_DELAY) { 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); habbo.alert(alertMessage);
return; return;
} }
@@ -57,14 +67,58 @@ public class CameraRoomThumbnailEvent extends MessageHandler {
if (room == null || !room.isOwner(habbo)) return; if (room == null || !room.isOwner(habbo)) return;
int count = this.packet.readInt(); 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); 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; BufferedImage theImage;
try (ByteBufInputStream in = new ByteBufInputStream(this.image)) { try (ByteBufInputStream in = new ByteBufInputStream(this.image)) {
theImage = ImageIO.read(in); theImage = ImageIO.read(in);
} catch (IOException e) { } 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")); habbo.alert(Emulator.getTexts().getValue("camera.error.creation"));
return; return;
} }
@@ -73,7 +127,7 @@ public class CameraRoomThumbnailEvent extends MessageHandler {
try { try {
ImageIO.write(theImage, "png", imageFile); ImageIO.write(theImage, "png", imageFile);
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); LOGGER.error("Failed to write thumbnail for room {}", room.getId(), e);
habbo.alert(Emulator.getTexts().getValue("camera.error.creation")); habbo.alert(Emulator.getTexts().getValue("camera.error.creation"));
return; return;
} }
@@ -87,4 +141,27 @@ public class CameraRoomThumbnailEvent extends MessageHandler {
&& imageBytes[0] == (byte) 0x89 && imageBytes[1] == 0x50 && imageBytes[0] == (byte) 0x89 && imageBytes[1] == 0x50
&& imageBytes[2] == 0x4E && imageBytes[3] == 0x47; && 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<ImageReader> 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();
}
}
}
}
} }