Add domain allow-list for script assets

This commit is contained in:
2026-01-25 14:01:53 +01:00
parent b115e16f11
commit b57420d727
17 changed files with 634 additions and 35 deletions

View File

@@ -22,6 +22,7 @@ public record AssetView(
String mediaType,
String originalMediaType,
AssetType assetType,
List<String> allowedDomains,
List<ScriptAssetAttachmentView> scriptAttachments,
Boolean audioLoop,
Integer audioDelayMillis,
@@ -54,6 +55,7 @@ public record AssetView(
visual.getMediaType(),
visual.getOriginalMediaType(),
asset.getAssetType(),
List.of(),
null,
null,
null,
@@ -87,6 +89,7 @@ public record AssetView(
audio.getMediaType(),
audio.getOriginalMediaType(),
asset.getAssetType(),
List.of(),
null,
audio.isAudioLoop(),
audio.getAudioDelayMillis(),
@@ -122,6 +125,7 @@ public record AssetView(
script.getMediaType(),
script.getOriginalMediaType(),
asset.getAssetType(),
script.getAllowedDomains(),
script.getAttachments(),
null,
null,

View File

@@ -14,6 +14,8 @@ public class CodeAssetRequest {
private Boolean isPublic;
private java.util.List<String> allowedDomains;
public String getName() {
return name;
}
@@ -45,4 +47,12 @@ public class CodeAssetRequest {
public void setIsPublic(Boolean isPublic) {
this.isPublic = isPublic;
}
public java.util.List<String> getAllowedDomains() {
return allowedDomains;
}
public void setAllowedDomains(java.util.List<String> allowedDomains) {
this.allowedDomains = allowedDomains;
}
}

View File

@@ -1,12 +1,17 @@
package dev.kruhlmann.imgfloat.model;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import java.util.ArrayList;
import java.util.List;
@Entity
@@ -33,6 +38,11 @@ public class ScriptAsset {
@Column(name = "source_file_id")
private String sourceFileId;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "script_asset_allowed_domains", joinColumns = @JoinColumn(name = "script_asset_id"))
@Column(name = "allowed_domain")
private List<String> allowedDomains = new ArrayList<>();
@Transient
private List<ScriptAssetAttachmentView> attachments = List.of();
@@ -49,6 +59,12 @@ public class ScriptAsset {
if (this.name == null || this.name.isBlank()) {
this.name = this.id;
}
if (this.allowedDomains == null) {
this.allowedDomains = new ArrayList<>();
}
if (this.attachments == null) {
this.attachments = List.of();
}
}
public String getId() {
@@ -115,6 +131,22 @@ public class ScriptAsset {
this.sourceFileId = sourceFileId;
}
public List<String> getAllowedDomains() {
return allowedDomains == null ? List.of() : List.copyOf(allowedDomains);
}
public void setAllowedDomains(List<String> allowedDomains) {
if (this.allowedDomains == null) {
this.allowedDomains = new ArrayList<>();
} else {
this.allowedDomains.clear();
}
if (allowedDomains == null) {
return;
}
this.allowedDomains.addAll(allowedDomains);
}
public List<ScriptAssetAttachmentView> getAttachments() {
return attachments == null ? List.of() : attachments;
}

View File

@@ -6,6 +6,7 @@ public record ScriptMarketplaceEntry(
String description,
String logoUrl,
String broadcaster,
java.util.List<String> allowedDomains,
long heartsCount,
boolean hearted
) {}

View File

@@ -39,6 +39,7 @@ import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
import dev.kruhlmann.imgfloat.service.media.MediaTypeRegistry;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
@@ -62,6 +63,8 @@ public class ChannelDirectoryService {
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]");
private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript";
private static final int MAX_ALLOWED_SCRIPT_DOMAINS = 32;
private static final Pattern ALLOWED_DOMAIN_PATTERN = Pattern.compile("^[a-z0-9.-]+(?::[0-9]{1,5})?$");
private final ChannelRepository channelRepository;
private final AssetRepository assetRepository;
@@ -374,6 +377,7 @@ public class ChannelDirectoryService {
script.setMediaType(optimized.mediaType());
script.setOriginalMediaType(mediaType);
script.setSourceFileId(asset.getId());
script.setAllowedDomains(List.of());
script.setAttachments(List.of());
scriptAssetRepository.save(script);
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
@@ -413,6 +417,7 @@ public class ChannelDirectoryService {
Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
enforceUploadLimit(bytes.length);
List<String> allowedDomains = normalizeAllowedDomains(request.getAllowedDomains());
Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(channel.getBroadcaster(), AssetType.SCRIPT));
@@ -440,6 +445,7 @@ public class ChannelDirectoryService {
script.setSourceFileId(sourceFile.getId());
script.setDescription(normalizeDescription(request.getDescription()));
script.setPublic(Boolean.TRUE.equals(request.getIsPublic()));
script.setAllowedDomains(allowedDomains);
script.setAttachments(List.of());
scriptAssetRepository.save(script);
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
@@ -458,6 +464,7 @@ public class ChannelDirectoryService {
String normalized = normalize(broadcaster);
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
enforceUploadLimit(bytes.length);
List<String> allowedDomains = normalizeAllowedDomains(request.getAllowedDomains());
return assetRepository
.findById(assetId)
@@ -491,6 +498,7 @@ public class ChannelDirectoryService {
if (request.getIsPublic() != null) {
script.setPublic(request.getIsPublic());
}
script.setAllowedDomains(allowedDomains);
script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE);
script.setMediaType(DEFAULT_CODE_MEDIA_TYPE);
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
@@ -635,6 +643,7 @@ public class ChannelDirectoryService {
script.getDescription(),
logoUrl,
broadcaster,
normalizeAllowedDomainsLenient(script.getAllowedDomains()),
0,
false
);
@@ -687,6 +696,7 @@ public class ChannelDirectoryService {
script.getDescription(),
logoUrl,
broadcaster,
normalizeAllowedDomainsLenient(script.getAllowedDomains()),
0,
false
);
@@ -715,6 +725,7 @@ public class ChannelDirectoryService {
entry.description(),
entry.logoUrl(),
entry.broadcaster(),
entry.allowedDomains(),
heartCount,
hearted
);
@@ -769,6 +780,7 @@ public class ChannelDirectoryService {
entry.description(),
entry.logoUrl(),
entry.broadcaster(),
entry.allowedDomains(),
counts.getOrDefault(entry.id(), 0L),
heartedIds.contains(entry.id())
)
@@ -845,6 +857,7 @@ public class ChannelDirectoryService {
script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId());
script.setLogoFileId(sourceScript.getLogoFileId());
script.setAllowedDomains(normalizeAllowedDomains(sourceScript.getAllowedDomains()));
script.setAttachments(List.of());
scriptAssetRepository.save(script);
@@ -889,6 +902,7 @@ public class ChannelDirectoryService {
if (sourceContent == null) {
return Optional.empty();
}
List<String> allowedDomains = normalizeAllowedDomains(seedScript.allowedDomains());
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
asset.setDisplayOrder(nextDisplayOrder(targetBroadcaster, AssetType.SCRIPT));
@@ -915,6 +929,7 @@ public class ChannelDirectoryService {
script.setMediaType(sourceContent.mediaType());
script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId());
script.setAllowedDomains(allowedDomains);
script.setAttachments(List.of());
scriptAssetRepository.save(script);
@@ -1555,6 +1570,61 @@ public class ChannelDirectoryService {
return trimmed.isBlank() ? null : trimmed;
}
private List<String> normalizeAllowedDomains(List<String> requestedDomains) {
if (requestedDomains == null || requestedDomains.isEmpty()) {
return List.of();
}
List<String> normalized = new ArrayList<>();
for (String raw : requestedDomains) {
if (raw == null) {
continue;
}
String candidate = raw.trim();
if (candidate.isEmpty()) {
continue;
}
String withScheme = candidate.contains("://") ? candidate : "https://" + candidate;
URI uri;
try {
uri = URI.create(withScheme);
} catch (IllegalArgumentException ex) {
throw new ResponseStatusException(BAD_REQUEST, "Invalid allowed domain: " + candidate, ex);
}
String host = uri.getHost();
if (host == null || host.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Invalid allowed domain: " + candidate);
}
String domain = host.toLowerCase(Locale.ROOT);
int port = uri.getPort();
if (port > 0) {
domain = domain + ":" + port;
}
if (!ALLOWED_DOMAIN_PATTERN.matcher(domain).matches()) {
throw new ResponseStatusException(BAD_REQUEST, "Invalid allowed domain: " + candidate);
}
if (normalized.contains(domain)) {
continue;
}
if (normalized.size() >= MAX_ALLOWED_SCRIPT_DOMAINS) {
throw new ResponseStatusException(
BAD_REQUEST,
"A maximum of 32 allowed domains are supported per script asset"
);
}
normalized.add(domain);
}
return new ArrayList<>(normalized);
}
private List<String> normalizeAllowedDomainsLenient(List<String> requestedDomains) {
try {
return normalizeAllowedDomains(requestedDomains);
} catch (ResponseStatusException ex) {
logger.warn("Ignoring invalid allowed domains: {}", ex.getReason());
return List.of();
}
}
private void removeScriptAssetFileIfOrphaned(String fileId) {
if (fileId == null || fileId.isBlank()) {
return;

View File

@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.service.media.AssetContent;
import java.io.IOException;
import java.net.URI;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -81,6 +82,7 @@ public class MarketplaceScriptSeedLoader {
String logoMediaType,
Optional<Path> sourcePath,
Optional<Path> logoPath,
List<String> allowedDomains,
List<SeedAttachment> attachments,
AtomicReference<byte[]> sourceBytes,
AtomicReference<byte[]> logoBytes
@@ -92,6 +94,7 @@ public class MarketplaceScriptSeedLoader {
description,
logoPath.isPresent() ? "/api/marketplace/scripts/" + id + "/logo" : null,
broadcaster,
allowedDomains,
0,
false
);
@@ -166,6 +169,7 @@ public class MarketplaceScriptSeedLoader {
if (attachments == null) {
return Optional.empty();
}
List<String> allowedDomains = normalizeAllowedDomains(metadata.allowedDomains());
return Optional.of(
new SeedScript(
@@ -177,6 +181,7 @@ public class MarketplaceScriptSeedLoader {
logoMediaType,
Optional.ofNullable(sourcePath),
Optional.ofNullable(logoPath),
allowedDomains,
attachments,
new AtomicReference<>(),
new AtomicReference<>()
@@ -284,6 +289,46 @@ public class MarketplaceScriptSeedLoader {
return Optional.of(new AssetContent(bytes, mediaType));
}
private List<String> normalizeAllowedDomains(List<String> requestedDomains) {
if (requestedDomains == null || requestedDomains.isEmpty()) {
return List.of();
}
List<String> normalized = new ArrayList<>();
for (String raw : requestedDomains) {
if (raw == null) {
continue;
}
String candidate = raw.trim();
if (candidate.isEmpty()) {
continue;
}
String withScheme = candidate.contains("://") ? candidate : "https://" + candidate;
try {
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) {
logger.warn("Skipping invalid allowed domain {}", candidate);
continue;
}
String value = host.toLowerCase(Locale.ROOT);
if (uri.getPort() > 0) {
value = value + ":" + uri.getPort();
}
if (normalized.contains(value)) {
continue;
}
if (normalized.size() >= 32) {
logger.warn("Trimming allowed domains for marketplace script {}, limit reached", candidate);
break;
}
normalized.add(value);
} catch (IllegalArgumentException ex) {
logger.warn("Skipping invalid allowed domain {}", candidate, ex);
}
}
return new ArrayList<>(normalized);
}
private static Optional<byte[]> readBytes(Path filePath) {
if (!Files.isRegularFile(filePath)) {
return Optional.empty();
@@ -296,7 +341,7 @@ public class MarketplaceScriptSeedLoader {
}
}
record ScriptSeedMetadata(String name, String description, String broadcaster) {
record ScriptSeedMetadata(String name, String description, String broadcaster, List<String> allowedDomains) {
static ScriptSeedMetadata read(Path path) {
if (!Files.isRegularFile(path)) {
return null;