mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add domain allow-list for script assets
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public record ScriptMarketplaceEntry(
|
||||
String description,
|
||||
String logoUrl,
|
||||
String broadcaster,
|
||||
java.util.List<String> allowedDomains,
|
||||
long heartsCount,
|
||||
boolean hearted
|
||||
) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user