From b57420d727a2dafd276cfbb97a4814ca71fd8547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Sun, 25 Jan 2026 14:01:53 +0100 Subject: [PATCH] Add domain allow-list for script assets --- README.md | 3 + .../lichess-tv-chess/metadata.json | 3 +- pom.xml | 2 +- .../kruhlmann/imgfloat/model/AssetView.java | 4 + .../imgfloat/model/CodeAssetRequest.java | 10 + .../kruhlmann/imgfloat/model/ScriptAsset.java | 32 +++ .../model/ScriptMarketplaceEntry.java | 1 + .../service/ChannelDirectoryService.java | 70 +++++ .../service/MarketplaceScriptSeedLoader.java | 47 +++- .../migration/V9__script_allowed_domains.sql | 6 + .../resources/static/js/broadcast/renderer.js | 2 + .../static/js/broadcast/script-worker.js | 182 ++++++++++++- src/main/resources/static/js/customAssets.js | 239 ++++++++++++++++-- src/main/resources/templates/admin.html | 20 ++ .../imgfloat/ChannelDirectoryServiceTest.java | 46 +++- .../SystemEnvironmentValidatorTest.java | 1 + .../org.mockito.plugins.MockMaker | 1 + 17 files changed, 634 insertions(+), 35 deletions(-) create mode 100644 src/main/resources/db/migration/V9__script_allowed_domains.sql create mode 100644 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/README.md b/README.md index a4b6554..dc4951e 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,14 @@ marketplace-scripts/ { "name": "Script display name", "description": "Short description", + "allowedDomains": ["api.example.com", "cdn.example.com:8443"] } ``` Only `name` is required. The folder name is used to identify the marketplace listing; when a script is imported, the asset receives a new generated ID. Media types are inferred from the files on disk. Attachments are loaded from the `attachments/` folder and appear in the imported script's attachments list, referenced by filename (for example `rotate.png`). Attachment filenames must be unique within a script. The logo is optional and remains separate from attachments; if you want to use the same image inside the script, add a copy of it under `attachments/`. +`allowedDomains` is optional; when provided it limits script `fetch` calls to the listed hostnames (up to 32 entries, ports allowed). Relative and same-origin requests remain permitted. + ### Build and run To run the application: diff --git a/doc/marketplace-scripts/lichess-tv-chess/metadata.json b/doc/marketplace-scripts/lichess-tv-chess/metadata.json index 380b9b2..c1b6260 100644 --- a/doc/marketplace-scripts/lichess-tv-chess/metadata.json +++ b/doc/marketplace-scripts/lichess-tv-chess/metadata.json @@ -1,4 +1,5 @@ { "name": "Lichess TV Chess", - "description": "Listen for !chess in chat, then render the current Lichess TV game live until it ends." + "description": "Listen for !chess in chat, then render the current Lichess TV game live until it ends.", + "allowedDomains": ["lichess.org"] } diff --git a/pom.xml b/pom.xml index d1c8b69..4b17d86 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ 17 3.2.5 6.4.4.Final - 1.18.3 + 1.14.13 0.8.14 UTF-8 UTF-8 diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java index 6deab9c..1273aa6 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java @@ -22,6 +22,7 @@ public record AssetView( String mediaType, String originalMediaType, AssetType assetType, + List allowedDomains, List 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, diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java index 0d8c7f7..f39ad22 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java @@ -14,6 +14,8 @@ public class CodeAssetRequest { private Boolean isPublic; + private java.util.List 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 getAllowedDomains() { + return allowedDomains; + } + + public void setAllowedDomains(java.util.List allowedDomains) { + this.allowedDomains = allowedDomains; + } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java index 7a5b399..7a79d9c 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java @@ -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 allowedDomains = new ArrayList<>(); + @Transient private List 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 getAllowedDomains() { + return allowedDomains == null ? List.of() : List.copyOf(allowedDomains); + } + + public void setAllowedDomains(List allowedDomains) { + if (this.allowedDomains == null) { + this.allowedDomains = new ArrayList<>(); + } else { + this.allowedDomains.clear(); + } + if (allowedDomains == null) { + return; + } + this.allowedDomains.addAll(allowedDomains); + } + public List getAttachments() { return attachments == null ? List.of() : attachments; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceEntry.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceEntry.java index 3ae58e4..8a99cbd 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceEntry.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceEntry.java @@ -6,6 +6,7 @@ public record ScriptMarketplaceEntry( String description, String logoUrl, String broadcaster, + java.util.List allowedDomains, long heartsCount, boolean hearted ) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 1167ca5..0048047 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -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 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 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 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 normalizeAllowedDomains(List requestedDomains) { + if (requestedDomains == null || requestedDomains.isEmpty()) { + return List.of(); + } + List 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 normalizeAllowedDomainsLenient(List 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; diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java b/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java index ddd0a5e..d1ea3f1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java @@ -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 sourcePath, Optional logoPath, + List allowedDomains, List attachments, AtomicReference sourceBytes, AtomicReference 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 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 normalizeAllowedDomains(List requestedDomains) { + if (requestedDomains == null || requestedDomains.isEmpty()) { + return List.of(); + } + List 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 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 allowedDomains) { static ScriptSeedMetadata read(Path path) { if (!Files.isRegularFile(path)) { return null; diff --git a/src/main/resources/db/migration/V9__script_allowed_domains.sql b/src/main/resources/db/migration/V9__script_allowed_domains.sql new file mode 100644 index 0000000..9b95862 --- /dev/null +++ b/src/main/resources/db/migration/V9__script_allowed_domains.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS script_asset_allowed_domains ( + script_asset_id TEXT NOT NULL, + allowed_domain TEXT NOT NULL, + PRIMARY KEY (script_asset_id, allowed_domain), + FOREIGN KEY (script_asset_id) REFERENCES script_assets(id) ON DELETE CASCADE +); diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index b421e55..cd716e5 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -720,6 +720,7 @@ export class BroadcastRenderer { width: scriptCanvas.width, height: scriptCanvas.height, attachments, + allowedDomains: Array.isArray(asset.allowedDomains) ? asset.allowedDomains : [], }, }, [offscreen]); } @@ -735,6 +736,7 @@ export class BroadcastRenderer { payload: { id: asset.id, attachments, + allowedDomains: Array.isArray(asset.allowedDomains) ? asset.allowedDomains : [], }, }); } diff --git a/src/main/resources/static/js/broadcast/script-worker.js b/src/main/resources/static/js/broadcast/script-worker.js index 4c6a18b..0548c15 100644 --- a/src/main/resources/static/js/broadcast/script-worker.js +++ b/src/main/resources/static/js/broadcast/script-worker.js @@ -9,6 +9,8 @@ const errorKeys = new Set(); const allowedImportUrls = new Set(); const nativeImportScripts = typeof self.importScripts === "function" ? self.importScripts.bind(self) : null; const sharedDependencyUrls = ["/js/vendor/three.min.js", "/js/vendor/GLTFLoader.js", "/js/vendor/OBJLoader.js"]; +const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null; +let activeScriptId = null; let chatMessages = []; let emoteCatalog = []; @@ -49,6 +51,148 @@ function loadSharedDependencies() { loadSharedDependencies(); +function sanitizeAllowedDomains(domains) { + if (!Array.isArray(domains)) { + return []; + } + const normalized = []; + domains.forEach((raw) => { + const candidate = typeof raw === "string" ? raw.trim() : ""; + if (!candidate) { + return; + } + const withScheme = candidate.includes("://") ? candidate : `https://${candidate}`; + try { + const url = new URL(withScheme, self.location?.href || "http://localhost"); + if (!url.hostname) { + return; + } + const host = url.hostname.toLowerCase(); + const port = url.port ? `:${url.port}` : ""; + const value = `${host}${port}`; + if (!normalized.includes(value) && normalized.length < 32) { + normalized.push(value); + } + } catch (_error) { + // ignore invalid domains from metadata/user input + } + }); + return normalized; +} + +function extractUrlFromInput(input) { + if (typeof input === "string") { + return input; + } + if (input && typeof input.url === "string") { + return input.url; + } + return ""; +} + +function extractDomain(url) { + try { + return new URL(url, self.location?.href || "http://localhost").host.toLowerCase(); + } catch (_error) { + return ""; + } +} + +function isSameOrigin(url) { + if (!self.location?.origin) { + return false; + } + try { + return new URL(url, self.location.origin).origin === self.location.origin; + } catch (_error) { + return false; + } +} + +function domainMatches(domain, allowed) { + if (!domain || !allowed) { + return false; + } + if (allowed.includes(":")) { + return domain === allowed; + } + return domain === allowed || domain.endsWith(`.${allowed}`); +} + +function isFetchAllowedForScript(script, targetUrl) { + const normalized = normalizeUrl(targetUrl); + if (!normalized) { + return { allowed: false, reason: "Invalid or empty URL" }; + } + if (isSameOrigin(normalized) || allowedFetchUrls.has(normalized)) { + return { allowed: true, normalized }; + } + const domain = extractDomain(normalized); + if (!domain) { + return { allowed: false, reason: "Invalid URL" }; + } + const allowedDomains = Array.isArray(script.allowedDomains) ? script.allowedDomains : []; + const allowed = allowedDomains.some((value) => domainMatches(domain, value)); + return allowed + ? { allowed: true, normalized } + : { allowed: false, reason: `Domain ${domain} is not in the allowed list.`, normalized }; +} + +function createScriptFetch(script) { + return async function scriptFetch(input, init) { + const targetUrl = extractUrlFromInput(input); + const decision = isFetchAllowedForScript(script, targetUrl); + console.info( + `Script ${script.id} fetch attempt`, + JSON.stringify({ + url: targetUrl || "", + normalized: decision.normalized || "", + allowedDomains: script.allowedDomains, + allowed: decision.allowed, + reason: decision.reason, + }) + ); + if (!decision.allowed) { + const message = `Fetch blocked for script ${script.id}: ${decision.reason}`; + const error = new Error(message); + console.error(message); + reportScriptError(script.id, "fetch", error); + return Promise.reject(error); + } + if (!nativeFetch) { + const error = new Error("Fetch is unavailable in this environment."); + reportScriptError(script.id, "fetch", error); + return Promise.reject(error); + } + try { + return await nativeFetch(input, init); + } catch (error) { + console.error(`Fetch failed for script ${script.id} (${targetUrl || ""})`, error); + reportScriptError(script.id, "fetch", error); + throw error; + } + }; +} + +function fetchForActiveScript(input, init) { + const script = activeScriptId ? scripts.get(activeScriptId) : null; + if (!script) { + const error = new Error("Fetch is only available inside a running script context."); + console.error(error.message); + return Promise.reject(error); + } + if (!script.context.fetch) { + script.context.fetch = createScriptFetch(script); + } + return script.context.fetch(input, init); +} + +if (nativeFetch) { + self.fetch = function sandboxedFetch(input, init) { + return fetchForActiveScript(input, init); + }; +} + function refreshAllowedFetchUrls() { allowedFetchUrls.clear(); scripts.forEach((script) => { @@ -125,6 +269,7 @@ function updateScriptContexts() { script.context.height = script.canvas?.height ?? 0; script.context.chatMessages = chatMessages; script.context.emoteCatalog = emoteCatalog; + script.context.allowedDomains = script.allowedDomains; }); } @@ -151,9 +296,12 @@ function ensureTickLoop() { script.context.deltaMs = deltaMs; script.context.elapsedMs = elapsedMs; try { + activeScriptId = script.id; script.tick(script.context, script.state); } catch (error) { console.error(`Script ${script.id} tick failed`, error); + } finally { + activeScriptId = null; } }); }, tickIntervalMs); @@ -168,7 +316,7 @@ function stopTickLoopIfIdle() { function createScriptHandlers(source, context, state, sourceLabel = "") { const contextPrelude = - "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio } = context;"; + "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets, chatMessages, emoteCatalog, playAudio, fetch, allowedDomains } = context;"; const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : ""; const factory = new Function( "context", @@ -212,6 +360,7 @@ self.addEventListener("message", (event) => { if (!payload?.id || !payload?.source || !payload?.canvas) { return; } + const allowedDomains = sanitizeAllowedDomains(payload.allowedDomains); const canvas = payload.canvas; canvas.width = payload.width || canvas.width; canvas.height = payload.height || canvas.height; @@ -229,6 +378,7 @@ self.addEventListener("message", (event) => { assets: Array.isArray(payload.attachments) ? payload.attachments : [], chatMessages, emoteCatalog, + allowedDomains, playAudio: (attachment) => { const attachmentId = typeof attachment === "string" ? attachment : attachment?.id; if (!attachmentId) { @@ -243,31 +393,41 @@ self.addEventListener("message", (event) => { }); }, }; + const script = { + id: payload.id, + allowedDomains, + canvas, + ctx, + context, + state, + init: null, + tick: null, + }; + context.fetch = createScriptFetch(script); let handlers = {}; try { + activeScriptId = payload.id; handlers = createScriptHandlers(payload.source, context, state, `user-script-${payload.id}.js`); } catch (error) { console.error(`Script ${payload.id} failed to initialize`, error); reportScriptError(payload.id, "initialize", error); return; + } finally { + activeScriptId = null; } - const script = { - id: payload.id, - canvas, - ctx, - context, - state, - init: handlers.init, - tick: handlers.tick, - }; + script.init = handlers.init; + script.tick = handlers.tick; scripts.set(payload.id, script); refreshAllowedFetchUrls(); if (script.init) { try { + activeScriptId = script.id; script.init(script.context, script.state); } catch (error) { console.error(`Script ${payload.id} init failed`, error); reportScriptError(payload.id, "init", error); + } finally { + activeScriptId = null; } } ensureTickLoop(); @@ -292,6 +452,8 @@ self.addEventListener("message", (event) => { return; } script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : []; + script.allowedDomains = sanitizeAllowedDomains(payload.allowedDomains); + script.context.allowedDomains = script.allowedDomains; refreshAllowedFetchUrls(); } diff --git a/src/main/resources/static/js/customAssets.js b/src/main/resources/static/js/customAssets.js index bcf837f..de22c09 100644 --- a/src/main/resources/static/js/customAssets.js +++ b/src/main/resources/static/js/customAssets.js @@ -30,10 +30,15 @@ export function createCustomAssetModal({ const attachmentInput = document.getElementById("custom-asset-attachment-file"); const attachmentList = document.getElementById("custom-asset-attachment-list"); const attachmentHint = document.getElementById("custom-asset-attachment-hint"); + const allowedDomainInput = document.getElementById("custom-asset-allowed-domain"); + const allowedDomainList = document.getElementById("custom-asset-allowed-domain-list"); + const allowedDomainAddButton = document.getElementById("custom-asset-allowed-domain-add"); + const allowedDomainHint = document.getElementById("custom-asset-allowed-domain-hint"); let currentAssetId = null; let pendingLogoFile = null; let logoRemoved = false; let attachmentState = []; + let allowedDomainState = []; let marketplaceEntries = []; const resetErrors = () => { @@ -48,6 +53,99 @@ export function createCustomAssetModal({ } }; + const normalizeAllowedDomain = (value) => { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const candidate = trimmed.includes("://") ? trimmed : `https://${trimmed}`; + try { + const url = new URL(candidate); + if (!url.hostname) { + return null; + } + const host = url.hostname.toLowerCase(); + const port = url.port ? `:${url.port}` : ""; + return `${host}${port}`; + } catch (_error) { + return null; + } + }; + + const setAllowedDomainState = (domains) => { + allowedDomainState = Array.isArray(domains) + ? domains + .map((domain) => normalizeAllowedDomain(domain)) + .filter((domain, index, list) => domain && list.indexOf(domain) === index) + : []; + renderAllowedDomains(); + if (allowedDomainInput) { + allowedDomainInput.value = ""; + } + }; + + const removeAllowedDomain = (domain) => { + allowedDomainState = allowedDomainState.filter((item) => item !== domain); + renderAllowedDomains(); + }; + + const addAllowedDomain = (value) => { + const normalized = normalizeAllowedDomain(value); + if (!normalized) { + showToast?.("Enter a valid domain like api.example.com.", "error"); + return; + } + if (allowedDomainState.includes(normalized)) { + showToast?.("Domain already added.", "info"); + if (allowedDomainInput) allowedDomainInput.value = ""; + return; + } + if (allowedDomainState.length >= 32) { + showToast?.("You can allow up to 32 domains per script.", "error"); + return; + } + allowedDomainState = [...allowedDomainState, normalized]; + renderAllowedDomains(); + if (allowedDomainInput) { + allowedDomainInput.value = ""; + } + }; + + function renderAllowedDomains() { + if (!allowedDomainList) { + return; + } + allowedDomainList.innerHTML = ""; + if (!allowedDomainState.length) { + const empty = document.createElement("li"); + empty.className = "attachment-empty"; + empty.textContent = "No external domains allowed (only same-origin requests)."; + allowedDomainList.appendChild(empty); + return; + } + allowedDomainState.forEach((domain) => { + const item = document.createElement("li"); + item.className = "attachment-item"; + const meta = document.createElement("div"); + meta.className = "attachment-meta"; + const name = document.createElement("strong"); + name.textContent = domain; + meta.appendChild(name); + + const actions = document.createElement("div"); + actions.className = "attachment-actions-row"; + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "secondary danger"; + remove.textContent = "Remove"; + remove.addEventListener("click", () => removeAllowedDomain(domain)); + actions.appendChild(remove); + + item.appendChild(meta); + item.appendChild(actions); + allowedDomainList.appendChild(item); + }); + } + const registerCodeEditorLint = () => { const CodeMirror = globalThis.CodeMirror; if (!CodeMirror?.registerHelper || CodeMirror.__customAssetLintRegistered) { @@ -282,6 +380,7 @@ export function createCustomAssetModal({ "function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};", ); setAttachmentState(null, []); + setAllowedDomainState([]); resetErrors(); openModal(); }; @@ -314,6 +413,7 @@ export function createCustomAssetModal({ setCodeReadOnly(true); setCodePlaceholder("Loading script..."); setAttachmentState(asset.id, asset.scriptAttachments || []); + setAllowedDomainState(asset.allowedDomains || []); openModal(); fetch(asset.url) @@ -373,7 +473,7 @@ export function createCustomAssetModal({ submitButton.disabled = true; submitButton.textContent = "Saving..."; } - saveCodeAsset({ name, src, assetId, description, isPublic }) + saveCodeAsset({ name, src, assetId, description, isPublic, allowedDomains: allowedDomainState }) .then((asset) => { if (asset) { return syncLogoChanges(asset).then((updated) => { @@ -495,14 +595,25 @@ export function createCustomAssetModal({ renderAttachmentList(); showToast?.("Attachment added.", "success"); } - }) - .catch((error) => { - console.error(error); - showToast?.(error?.message || "Unable to upload attachment. Please try again.", "error"); - }) - .finally(() => { - attachmentInput.value = ""; - }); + }) + .catch((error) => { + console.error(error); + showToast?.(error?.message || "Unable to upload attachment. Please try again.", "error"); + }) + .finally(() => { + attachmentInput.value = ""; + }); + }); + } + if (allowedDomainAddButton) { + allowedDomainAddButton.addEventListener("click", () => addAllowedDomain(allowedDomainInput?.value)); + } + if (allowedDomainInput) { + allowedDomainInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + addAllowedDomain(event.target?.value); + } }); } @@ -642,12 +753,13 @@ export function createCustomAssetModal({ }); } - function saveCodeAsset({ name, src, assetId, description, isPublic }) { + function saveCodeAsset({ name, src, assetId, description, isPublic, allowedDomains }) { const payload = { name, source: src, description: description || null, isPublic, + allowedDomains: Array.isArray(allowedDomains) ? allowedDomains : [], }; const method = assetId ? "PUT" : "POST"; const url = assetId @@ -810,6 +922,16 @@ export function createCustomAssetModal({ content.appendChild(title); content.appendChild(description); content.appendChild(meta); + if (Array.isArray(entry.allowedDomains) && entry.allowedDomains.length) { + const domains = document.createElement("small"); + domains.className = "marketplace-domains"; + const summary = + entry.allowedDomains.length > 3 + ? `${entry.allowedDomains.slice(0, 3).join(", ")}, …` + : entry.allowedDomains.join(", "); + domains.textContent = `Allowed domains: ${summary}`; + content.appendChild(domains); + } const actions = document.createElement("div"); actions.className = "marketplace-actions"; @@ -854,25 +976,34 @@ export function createCustomAssetModal({ return; } const target = marketplaceChannelSelect?.value || broadcaster; - fetch(`/api/marketplace/scripts/${entry.id}/import`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ targetBroadcaster: target }), - }) - .then((response) => { - if (!response.ok) { - throw new Error("Failed to import script"); + const allowedDomains = Array.isArray(entry.allowedDomains) ? entry.allowedDomains.filter(Boolean) : []; + confirmDomainImport(allowedDomains, target) + .then((confirmed) => { + if (!confirmed) { + return null; } - return response.json(); + return fetch(`/api/marketplace/scripts/${entry.id}/import`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetBroadcaster: target }), + }).then((response) => { + if (!response.ok) { + throw new Error("Failed to import script"); + } + return response.json(); + }); }) .then((asset) => { + if (!asset) { + return; + } closeMarketplaceModal(); showToast?.("Script imported.", "success"); onAssetSaved?.(asset); }) .catch((error) => { console.error(error); - showToast?.("Unable to import script. Please try again.", "error"); + showToast?.(error?.message || "Unable to import script. Please try again.", "error"); }); } @@ -1043,4 +1174,72 @@ export function createCustomAssetModal({ return undefined; } + + function confirmDomainImport(domains, target) { + if (!Array.isArray(domains) || domains.length === 0) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = "modal"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + + const dialog = document.createElement("div"); + dialog.className = "modal-card"; + + const title = document.createElement("h3"); + title.textContent = "Allow external domains?"; + dialog.appendChild(title); + + const copy = document.createElement("p"); + copy.textContent = `This script requests network access to the following domains on ${target}:`; + dialog.appendChild(copy); + + const list = document.createElement("ul"); + list.className = "domain-list"; + domains.forEach((domain) => { + const item = document.createElement("li"); + item.textContent = domain; + list.appendChild(item); + }); + dialog.appendChild(list); + + const buttons = document.createElement("div"); + buttons.className = "modal-actions"; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "secondary"; + cancel.textContent = "Cancel"; + cancel.addEventListener("click", () => { + cleanup(); + resolve(false); + }); + const confirm = document.createElement("button"); + confirm.type = "button"; + confirm.className = "primary"; + confirm.textContent = "Allow & import"; + confirm.addEventListener("click", () => { + cleanup(); + resolve(true); + }); + buttons.appendChild(cancel); + buttons.appendChild(confirm); + dialog.appendChild(buttons); + + overlay.addEventListener("click", (event) => { + if (event.target === overlay) { + cleanup(); + resolve(false); + } + }); + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + function cleanup() { + overlay.remove(); + } + }); + } } diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 8f17ecf..0d4d1bf 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -447,6 +447,26 @@ rows="3" > +
+ +
+ + +
+

+ Scripts may fetch data only from these domains (max 32). Relative and Imgfloat URLs are always + allowed. +

+
    +
    diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index d620bc7..f31b803 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -14,6 +14,8 @@ import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.AudioAsset; import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.ScriptAsset; +import dev.kruhlmann.imgfloat.model.CodeAssetRequest; +import dev.kruhlmann.imgfloat.model.AssetType; import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest; @@ -219,6 +221,31 @@ class ChannelDirectoryServiceTest { .anyMatch((entry) -> "rotating-logo".equals(entry.id())); } + @Test + void clearsAllowedDomainsWhenUpdatedToEmpty() { + CodeAssetRequest request = new CodeAssetRequest(); + request.setName("Test script"); + request.setSource("exports.init = function() {}; exports.tick = function() {};"); + request.setAllowedDomains(List.of("example.com")); + AssetView created = service.createCodeAsset("caster", request, "caster").orElseThrow(); + scriptAssetRepository.findById(created.id()).ifPresent((script) -> script.setSourceFileId(created.id())); + scriptAssetFileRepository.save(new dev.kruhlmann.imgfloat.model.ScriptAssetFile("caster", AssetType.SCRIPT) { + { + setId(created.id()); + } + }); + + CodeAssetRequest updated = new CodeAssetRequest(); + updated.setName("Test script"); + updated.setSource(request.getSource()); + updated.setAllowedDomains(List.of()); // clear allowlist + AssetView saved = service.updateCodeAsset("caster", created.id(), updated, "caster").orElseThrow(); + + ScriptAsset script = scriptAssetRepository.findById(created.id()).orElseThrow(); + assertThat(script.getAllowedDomains()).isEmpty(); + assertThat(saved.allowedDomains()).isEmpty(); + } + private byte[] samplePng() throws IOException { BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -247,6 +274,7 @@ class ChannelDirectoryServiceTest { Map visualAssets = new ConcurrentHashMap<>(); Map audioAssets = new ConcurrentHashMap<>(); Map scriptAssets = new ConcurrentHashMap<>(); + Map scriptFiles = new ConcurrentHashMap<>(); when(channelRepository.findById(anyString())).thenAnswer((invocation) -> Optional.ofNullable(channels.get(invocation.getArgument(0))) @@ -353,12 +381,26 @@ class ChannelDirectoryServiceTest { return scriptAssets .values() .stream() - .filter((script) -> ids.contains(script.getId())) - .toList(); + .filter((script) -> ids.contains(script.getId())) + .toList(); }); doAnswer((invocation) -> scriptAssets.remove(invocation.getArgument(0, String.class))) .when(scriptAssetRepository) .deleteById(anyString()); + + when(scriptAssetFileRepository.save(any(dev.kruhlmann.imgfloat.model.ScriptAssetFile.class))).thenAnswer( + (invocation) -> { + dev.kruhlmann.imgfloat.model.ScriptAssetFile file = invocation.getArgument(0); + if (file.getId() == null) { + file.setId(java.util.UUID.randomUUID().toString()); + } + scriptFiles.put(file.getId(), file); + return file; + } + ); + when(scriptAssetFileRepository.findById(anyString())).thenAnswer((invocation) -> + Optional.ofNullable(scriptFiles.get(invocation.getArgument(0))) + ); } private List filterAssetsByBroadcaster(Collection assets, String broadcaster) { diff --git a/src/test/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidatorTest.java b/src/test/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidatorTest.java index eb51f1e..c81542c 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidatorTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidatorTest.java @@ -50,6 +50,7 @@ class SystemEnvironmentValidatorTest { ReflectionTestUtils.setField(validator, "assetsPath", "/tmp/assets"); ReflectionTestUtils.setField(validator, "previewsPath", "/tmp/previews"); ReflectionTestUtils.setField(validator, "dbPath", "/tmp/db"); + ReflectionTestUtils.setField(validator, "auditDbPath", "/tmp/audit.db"); ReflectionTestUtils.setField(validator, "initialSysadmin", "admin"); ReflectionTestUtils.setField(validator, "githubClientOwner", "owner"); ReflectionTestUtils.setField(validator, "githubClientRepo", "repo"); diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca8778f --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker