From 7418bca56bfbd733ea3530d5a60f644620572ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 11 Dec 2025 14:04:21 +0100 Subject: [PATCH] Add asset restrictions --- Makefile | 2 +- README.md | 83 ------------------- .../config/SystemEnvironmentValidator.java | 31 +++++++ .../controller/ChannelApiController.java | 16 +++- .../service/ChannelDirectoryService.java | 19 ++++- .../service/media/MediaDetectionService.java | 65 ++++++++++----- src/main/resources/application.yml | 4 - .../imgfloat/ChannelDirectoryServiceTest.java | 2 +- .../media/MediaDetectionServiceTest.java | 24 +++--- 9 files changed, 122 insertions(+), 124 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java diff --git a/Makefile b/Makefile index 593480a..0357319 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ build: .PHONY: run run: - test -f .env && . ./.env; mvn spring-boot:run + test -f .env && . ./.env; IMGFLOAT_UPLOAD_MAX_BYTES=16777216 mvn spring-boot:run .PHONY: watch watch: diff --git a/README.md b/README.md index d819e31..77f6a49 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,3 @@ # Imgfloat A Spring Boot overlay server for Twitch broadcasters and their channel admins. Broadcasters can authorize via Twitch OAuth and invite channel admins to manage images that float over a transparent canvas. Updates are pushed in real time over WebSockets so OBS browser sources stay in sync. - -## Features -- Twitch OAuth (OAuth2 login) with broadcaster and channel admin access controls. -- Admin console with Twitch player embed and canvas preview. -- Broadcaster overlay view optimized for OBS browser sources. -- Real-time asset creation, movement, resize, rotation, visibility toggles, and deletion via STOMP/WebSockets. -- In-memory channel directory optimized with lock-free collections for fast updates. -- Optional SSL with local self-signed keystore support. -- Dockerfile, Makefile, CI workflow, and Maven build. -- OpenAPI/Swagger UI docs available at `/swagger-ui.html`. - -## Getting started -### Prerequisites -- Java 17+ -- Maven 3.9+ -- Twitch Developer credentials (Client ID/Secret) - -### Local run -```bash -TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret \ -TWITCH_REDIRECT_URI=http://localhost:8080/login/oauth2/code/twitch mvn spring-boot:run -``` -The default server port is `8080`. Log in via `/oauth2/authorization/twitch`. The redirect URI above is what Twitch should be configured to call for local development. - -### Hot reload during development -- The project includes Spring Boot DevTools so Java and Thymeleaf changes trigger a restart automatically when you run `make run` (which now forks the Spring Boot process so devtools can watch the classpath). -- Static assets under `src/main/resources/static` are refreshed through the built-in LiveReload server from DevTools; install a LiveReload browser extension to automatically reload the overlay or dashboard when CSS/JS files change. - -### Enable TLS locally -```bash -make ssl -SSL_ENABLED=true SSL_KEYSTORE_PATH=file:$(pwd)/local/keystore.p12 \ -TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret \ -mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8443" -``` - -### Make targets -- `make run` – start the dev server (exports `TWITCH_REDIRECT_URI` to `http://localhost:8080/login/oauth2/code/twitch` if unset). -- `make test` – run unit/integration tests. -- `make package` – build the runnable jar. -- `make docker-build` / `make docker-run` – containerize and run the service. -- `make ssl` – create a self-signed PKCS12 keystore in `./local`. - -### Docker -```bash -make docker-build -TWITCH_CLIENT_ID=your_id TWITCH_CLIENT_SECRET=your_secret docker run -p 8080:8080 imgfloat:latest -``` - -### Data storage configuration -- `IMGFLOAT_DB_PATH` – filesystem location for the SQLite database (default: `imgfloat.db`). -- `IMGFLOAT_ASSETS_PATH` – root directory where uploaded assets are persisted (default: `assets`). -- `IMGFLOAT_PREVIEWS_PATH` – root directory for generated preview images (default: `previews`). - -### Docker Compose example with persistent storage -```yaml -services: - imgfloat: - image: imgfloat:latest - environment: - TWITCH_CLIENT_ID: your_id - TWITCH_CLIENT_SECRET: your_secret - TWITCH_REDIRECT_URI: https://yourdomain/login/oauth2/code/twitch - IMGFLOAT_DB_PATH: /var/lib/imgfloat/imgfloat.db - IMGFLOAT_ASSETS_PATH: /var/lib/imgfloat/assets - IMGFLOAT_PREVIEWS_PATH: /var/lib/imgfloat/previews - ports: - - "8080:8080" - volumes: - - ./data/db:/var/lib/imgfloat - - ./data/assets:/var/lib/imgfloat/assets - - ./data/previews:/var/lib/imgfloat/previews -``` -The `volumes` map local folders into the container so database and uploaded files survive container restarts. - -### OAuth configuration -Spring Boot reads Twitch credentials from `TWITCH_CLIENT_ID` and `TWITCH_CLIENT_SECRET`. The redirect URI comes from `TWITCH_REDIRECT_URI` (defaulting to `{baseUrl}/login/oauth2/code/twitch`). - -### CI -GitHub Actions runs `mvn verify` on pushes and pull requests via `.github/workflows/ci.yml`. - -### License -MIT diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java new file mode 100644 index 0000000..7828536 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java @@ -0,0 +1,31 @@ +package dev.kruhlmann.imgfloat.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +public class SystemEnvironmentValidator implements ApplicationRunner { + + @Value("${IMGFLOAT_UPLOAD_MAX_BYTES:#{null}}") + private Long maxUploadBytes; + + @Override + public void run(ApplicationArguments args) { + List missing = new ArrayList<>(); + + if (maxUploadBytes == null) + missing.add("IMGFLOAT_UPLOAD_MAX_BYTES"); + + if (!missing.isEmpty()) { + throw new IllegalStateException( + "Missing required environment variables:\n - " + + String.join("\n - ", missing) + ); + } + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 4ae5311..5de93e4 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -13,8 +13,9 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -248,6 +249,8 @@ public class ChannelApiController { LOG.debug("Serving asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName()); return channelDirectoryService.getAssetContent(broadcaster, assetId) .map(content -> ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType())) .body(content.bytes())) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); @@ -255,6 +258,8 @@ public class ChannelApiController { return channelDirectoryService.getVisibleAssetContent(broadcaster, assetId) .map(content -> ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType())) .body(content.bytes())) .orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Asset not available")); @@ -275,6 +280,7 @@ public class ChannelApiController { LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster); return channelDirectoryService.getAssetPreview(broadcaster, assetId, true) .map(content -> ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") .contentType(MediaType.parseMediaType(content.mediaType())) .body(content.bytes())) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found")); @@ -282,11 +288,19 @@ public class ChannelApiController { return channelDirectoryService.getAssetPreview(broadcaster, assetId, false) .map(content -> ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") .contentType(MediaType.parseMediaType(content.mediaType())) .body(content.bytes())) .orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "Preview not available")); } + private String contentDispositionFor(String mediaType) { + if (mediaType != null && dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) { + return "inline"; + } + return "attachment"; + } + @DeleteMapping("/assets/{assetId}") public ResponseEntity delete(@PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index cc30d9a..f53d66b 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -13,6 +13,7 @@ import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -32,6 +33,7 @@ import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; @Service public class ChannelDirectoryService { @@ -48,19 +50,22 @@ public class ChannelDirectoryService { private final AssetStorageService assetStorageService; private final MediaDetectionService mediaDetectionService; private final MediaOptimizationService mediaOptimizationService; + private final long maxUploadBytes; public ChannelDirectoryService(ChannelRepository channelRepository, AssetRepository assetRepository, SimpMessagingTemplate messagingTemplate, AssetStorageService assetStorageService, MediaDetectionService mediaDetectionService, - MediaOptimizationService mediaOptimizationService) { + MediaOptimizationService mediaOptimizationService, + @Value("${IMGFLOAT_UPLOAD_MAX_BYTES:26214400}") long maxUploadBytes) { this.channelRepository = channelRepository; this.assetRepository = assetRepository; this.messagingTemplate = messagingTemplate; this.assetStorageService = assetStorageService; this.mediaDetectionService = mediaDetectionService; this.mediaOptimizationService = mediaOptimizationService; + this.maxUploadBytes = maxUploadBytes; } public Channel getOrCreateChannel(String broadcaster) { @@ -123,8 +128,18 @@ public class ChannelDirectoryService { public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { Channel channel = getOrCreateChannel(broadcaster); + long reportedSize = file.getSize(); + if (reportedSize > 0 && reportedSize > maxUploadBytes) { + throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit"); + } + byte[] bytes = file.getBytes(); - String mediaType = mediaDetectionService.detectMediaType(file, bytes); + if (bytes.length > maxUploadBytes) { + throw new ResponseStatusException(PAYLOAD_TOO_LARGE, "Upload exceeds limit"); + } + + String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type")); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); if (optimized == null) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java index ac25609..c24da84 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java @@ -8,41 +8,66 @@ import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URLConnection; +import java.util.Map; import java.util.Optional; +import java.util.Set; @Service public class MediaDetectionService { private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class); + private static final Map EXTENSION_TYPES = Map.ofEntries( + Map.entry("png", "image/png"), + Map.entry("jpg", "image/jpeg"), + Map.entry("jpeg", "image/jpeg"), + Map.entry("gif", "image/gif"), + Map.entry("webp", "image/webp"), + Map.entry("mp4", "video/mp4"), + Map.entry("webm", "video/webm"), + Map.entry("mov", "video/quicktime"), + Map.entry("mp3", "audio/mpeg"), + Map.entry("wav", "audio/wav"), + Map.entry("ogg", "audio/ogg") + ); + private static final Set ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values()); - public String detectMediaType(MultipartFile file, byte[] bytes) { - String contentType = Optional.ofNullable(file.getContentType()).orElse("application/octet-stream"); - if (!"application/octet-stream".equals(contentType) && !contentType.isBlank()) { - return contentType; + public Optional detectAllowedMediaType(MultipartFile file, byte[] bytes) { + Optional detected = detectMediaType(bytes) + .filter(MediaDetectionService::isAllowedMediaType); + + if (detected.isPresent()) { + return detected; } + Optional declared = Optional.ofNullable(file.getContentType()) + .filter(MediaDetectionService::isAllowedMediaType); + if (declared.isPresent()) { + return declared; + } + + return Optional.ofNullable(file.getOriginalFilename()) + .map(name -> name.replaceAll("^.*\\.", "").toLowerCase()) + .map(EXTENSION_TYPES::get) + .filter(MediaDetectionService::isAllowedMediaType); + } + + private Optional detectMediaType(byte[] bytes) { try (var stream = new ByteArrayInputStream(bytes)) { String guessed = URLConnection.guessContentTypeFromStream(stream); if (guessed != null && !guessed.isBlank()) { - return guessed; + return Optional.of(guessed); } } catch (IOException e) { logger.warn("Unable to detect content type from stream", e); } - return Optional.ofNullable(file.getOriginalFilename()) - .map(name -> name.replaceAll("^.*\\.", "").toLowerCase()) - .map(ext -> switch (ext) { - case "png" -> "image/png"; - case "jpg", "jpeg" -> "image/jpeg"; - case "gif" -> "image/gif"; - case "mp4" -> "video/mp4"; - case "webm" -> "video/webm"; - case "mov" -> "video/quicktime"; - case "mp3" -> "audio/mpeg"; - case "wav" -> "audio/wav"; - case "ogg" -> "audio/ogg"; - default -> "application/octet-stream"; - }) - .orElse("application/octet-stream"); + return Optional.empty(); + } + + public static boolean isAllowedMediaType(String mediaType) { + return mediaType != null && ALLOWED_MEDIA_TYPES.contains(mediaType.toLowerCase()); + } + + public static boolean isInlineDisplayType(String mediaType) { + return mediaType != null && (mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/")); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d5d59b8..19fd4b0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,10 +16,6 @@ spring: enabled: true livereload: enabled: true - servlet: - multipart: - max-file-size: 256MB - max-request-size: 256MB thymeleaf: cache: false datasource: diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index 65d6c9d..ec53b45 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -61,7 +61,7 @@ class ChannelDirectoryServiceTest { MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService); MediaDetectionService mediaDetectionService = new MediaDetectionService(); service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, - assetStorageService, mediaDetectionService, mediaOptimizationService); + assetStorageService, mediaDetectionService, mediaOptimizationService, 26214400L); } @Test diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java index 6b3fa53..e15bcc4 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java @@ -11,24 +11,24 @@ class MediaDetectionServiceTest { private final MediaDetectionService service = new MediaDetectionService(); @Test - void prefersProvidedContentType() throws IOException { - MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", new byte[]{1, 2, 3}); - - assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("image/png"); - } - - @Test - void fallsBackToFilenameAndStream() throws IOException { + void acceptsMagicBytesOverDeclaredType() throws IOException { byte[] png = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}; - MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, png); + MockMultipartFile file = new MockMultipartFile("file", "image.png", "text/plain", png); - assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("image/png"); + assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png"); } @Test - void returnsOctetStreamForUnknownType() throws IOException { + void fallsBackToFilenameAllowlist() throws IOException { + MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[]{1, 2, 3}); + + assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png"); + } + + @Test + void rejectsUnknownTypes() throws IOException { MockMultipartFile file = new MockMultipartFile("file", "unknown.bin", null, new byte[]{1, 2, 3}); - assertThat(service.detectMediaType(file, file.getBytes())).isEqualTo("application/octet-stream"); + assertThat(service.detectAllowedMediaType(file, file.getBytes())).isEmpty(); } }