Add asset restrictions

This commit is contained in:
2025-12-11 14:04:21 +01:00
parent 0f6e840807
commit 7418bca56b
9 changed files with 122 additions and 124 deletions

View File

@@ -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<String> 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)
);
}
}
}

View File

@@ -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,

View File

@@ -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<AssetView> 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) {

View File

@@ -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<String, String> 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<String> 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<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) {
Optional<String> detected = detectMediaType(bytes)
.filter(MediaDetectionService::isAllowedMediaType);
if (detected.isPresent()) {
return detected;
}
Optional<String> 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<String> 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/"));
}
}

View File

@@ -16,10 +16,6 @@ spring:
enabled: true
livereload:
enabled: true
servlet:
multipart:
max-file-size: 256MB
max-request-size: 256MB
thymeleaf:
cache: false
datasource:

View File

@@ -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

View File

@@ -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();
}
}