|
| 1 | +package io.flightdeck.api; |
| 2 | + |
| 3 | +import java.util.Collections; |
| 4 | +import java.util.LinkedHashMap; |
| 5 | +import java.util.Map; |
| 6 | + |
| 7 | +/** |
| 8 | + * Resolves a logical callback-service name to the trusted base URL configured for |
| 9 | + * it, then appends the fixed callback path ({@value #CALLBACK_PATH}). |
| 10 | + * |
| 11 | + * <p>The mapping is loaded once from the {@code ALLOWED_HOST_MAPPING} environment |
| 12 | + * variable: a comma-separated list of {@code name:baseUrl} entries. Only the first |
| 13 | + * colon of each entry separates the name from the URL, so the base URL keeps its |
| 14 | + * own {@code scheme://host[:port]} colons: |
| 15 | + * |
| 16 | + * <pre> |
| 17 | + * ALLOWED_HOST_MAPPING=my-agent-a:https://hosta.local,my-agent-c:http://hostc.local |
| 18 | + * </pre> |
| 19 | + * |
| 20 | + * <p><b>Why this exists.</b> A caller (a peer agent) supplies only the service |
| 21 | + * <em>name</em> in its {@code reply} descriptor; the destination URL is chosen |
| 22 | + * here from operator-controlled config, never from caller input. An untrusted |
| 23 | + * caller therefore cannot steer the server-side callback at an arbitrary host — |
| 24 | + * the SSRF primitive that an attacker-controlled {@code endpoint} would create is |
| 25 | + * structurally removed. Unknown names fail closed. |
| 26 | + */ |
| 27 | +final class CallbackRegistry { |
| 28 | + |
| 29 | + /** Fixed path appended to every resolved base URL. */ |
| 30 | + static final String CALLBACK_PATH = "/api/tools/response"; |
| 31 | + |
| 32 | + private static final Map<String, String> MAPPING = |
| 33 | + parse(ChatApiApp.env("ALLOWED_HOST_MAPPING", "")); |
| 34 | + |
| 35 | + private CallbackRegistry() {} |
| 36 | + |
| 37 | + /** True if {@code service} resolves to a configured base URL. */ |
| 38 | + static boolean isKnown(String service) { |
| 39 | + return service != null && MAPPING.containsKey(service); |
| 40 | + } |
| 41 | + |
| 42 | + /** |
| 43 | + * Resolves the full callback URL for a service name. |
| 44 | + * |
| 45 | + * @throws IllegalArgumentException if the name is not configured (fail closed) |
| 46 | + */ |
| 47 | + static String resolve(String service) { |
| 48 | + String base = service == null ? null : MAPPING.get(service); |
| 49 | + if (base == null) { |
| 50 | + throw new IllegalArgumentException("unknown callbackService: " + service); |
| 51 | + } |
| 52 | + return toCallbackUrl(base); |
| 53 | + } |
| 54 | + |
| 55 | + /** Strips any trailing slash from the base URL and appends the fixed callback path. */ |
| 56 | + static String toCallbackUrl(String base) { |
| 57 | + String trimmed = base.endsWith("/") ? base.substring(0, base.length() - 1) : base; |
| 58 | + return trimmed + CALLBACK_PATH; |
| 59 | + } |
| 60 | + |
| 61 | + /** Parses {@code name:baseUrl} entries, splitting each on its FIRST colon only. */ |
| 62 | + static Map<String, String> parse(String raw) { |
| 63 | + Map<String, String> mapping = new LinkedHashMap<>(); |
| 64 | + if (raw == null || raw.isBlank()) { |
| 65 | + return Collections.unmodifiableMap(mapping); |
| 66 | + } |
| 67 | + for (String entry : raw.split(",")) { |
| 68 | + String e = entry.trim(); |
| 69 | + if (e.isEmpty()) continue; |
| 70 | + int sep = e.indexOf(':'); |
| 71 | + String name = sep > 0 ? e.substring(0, sep).trim() : ""; |
| 72 | + String url = sep > 0 ? e.substring(sep + 1).trim() : ""; |
| 73 | + if (name.isEmpty() || url.isEmpty()) { |
| 74 | + throw new IllegalArgumentException( |
| 75 | + "Malformed ALLOWED_HOST_MAPPING entry (expected name:baseUrl): " + entry); |
| 76 | + } |
| 77 | + mapping.put(name, url); |
| 78 | + } |
| 79 | + return Collections.unmodifiableMap(mapping); |
| 80 | + } |
| 81 | +} |
0 commit comments