|
|
@@ -0,0 +1,133 @@
|
|
|
+package shop.alien.store.util;
|
|
|
+
|
|
|
+import java.net.IDN;
|
|
|
+import java.net.URI;
|
|
|
+import java.net.URISyntaxException;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
+import java.util.Locale;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 短链目标地址校验:HTTPS(可配置是否允许 HTTP)、长度、内网/本地地址、可选域名白名单。
|
|
|
+ */
|
|
|
+public final class ShortLinkUrlValidator {
|
|
|
+
|
|
|
+ private static final int MAX_URL_LENGTH = 2048;
|
|
|
+
|
|
|
+ private ShortLinkUrlValidator() {
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param longUrl 原始 URL
|
|
|
+ * @param allowedHosts 允许的 host(小写),如 {@code www.example.com};为空表示不限制 host(仍会拦截内网地址,除非 {@code allowLocalNetworkTarget})
|
|
|
+ * @param httpsOnly true 时仅允许 https
|
|
|
+ * @param allowLocalNetworkTarget true 时不拦截 localhost/内网 IP(仅用于本地开发,禁止线上开启)
|
|
|
+ * @return 规范化后的 URL(trim)
|
|
|
+ */
|
|
|
+ public static String validateAndNormalize(String longUrl, Set<String> allowedHosts, boolean httpsOnly,
|
|
|
+ boolean allowLocalNetworkTarget) {
|
|
|
+ if (longUrl == null || longUrl.trim().isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("长链接不能为空");
|
|
|
+ }
|
|
|
+ String trimmed = longUrl.trim();
|
|
|
+ if (trimmed.length() > MAX_URL_LENGTH) {
|
|
|
+ throw new IllegalArgumentException("长链接过长");
|
|
|
+ }
|
|
|
+ URI uri;
|
|
|
+ try {
|
|
|
+ uri = new URI(trimmed);
|
|
|
+ } catch (URISyntaxException e) {
|
|
|
+ throw new IllegalArgumentException("长链接格式无效");
|
|
|
+ }
|
|
|
+ String scheme = uri.getScheme();
|
|
|
+ if (scheme == null) {
|
|
|
+ throw new IllegalArgumentException("长链接需包含协议(https)");
|
|
|
+ }
|
|
|
+ String s = scheme.toLowerCase(Locale.ROOT);
|
|
|
+ if (httpsOnly) {
|
|
|
+ if (!"https".equals(s)) {
|
|
|
+ throw new IllegalArgumentException("仅支持 https 长链接");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (!"https".equals(s) && !"http".equals(s)) {
|
|
|
+ throw new IllegalArgumentException("仅支持 http / https 长链接");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ String host = uri.getHost();
|
|
|
+ if (host == null || host.isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("长链接缺少主机名");
|
|
|
+ }
|
|
|
+ String hostLower = IDN.toUnicode(host).toLowerCase(Locale.ROOT);
|
|
|
+ if (!allowLocalNetworkTarget && isBlockedHost(hostLower)) {
|
|
|
+ throw new IllegalArgumentException("不允许使用该主机地址");
|
|
|
+ }
|
|
|
+ if (allowedHosts != null && !allowedHosts.isEmpty()) {
|
|
|
+ if (!hostMatchesAllowlist(hostLower, allowedHosts)) {
|
|
|
+ throw new IllegalArgumentException("长链接域名不在允许列表内");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return trimmed;
|
|
|
+ }
|
|
|
+
|
|
|
+ public static Set<String> parseAllowedHostsCsv(String csv) {
|
|
|
+ if (csv == null || csv.trim().isEmpty()) {
|
|
|
+ return Collections.emptySet();
|
|
|
+ }
|
|
|
+ return Arrays.stream(csv.split(","))
|
|
|
+ .map(String::trim)
|
|
|
+ .filter(s -> !s.isEmpty())
|
|
|
+ .map(s -> s.toLowerCase(Locale.ROOT))
|
|
|
+ .collect(Collectors.toCollection(LinkedHashSet::new));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean hostMatchesAllowlist(String host, Set<String> allowedHosts) {
|
|
|
+ for (String allowed : allowedHosts) {
|
|
|
+ if (allowed == null || allowed.isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (host.equals(allowed) || host.endsWith("." + allowed)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isBlockedHost(String hostLower) {
|
|
|
+ if ("localhost".equals(hostLower)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if ("0.0.0.0".equals(hostLower) || "::1".equals(hostLower)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // IPv4 简单规则:127.*、10.*、192.168.*、169.254.*、172.16-31.*
|
|
|
+ if (hostLower.startsWith("127.")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (hostLower.startsWith("10.")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (hostLower.startsWith("192.168.")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (hostLower.startsWith("169.254.")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (hostLower.startsWith("172.")) {
|
|
|
+ String[] parts = hostLower.split("\\.");
|
|
|
+ if (parts.length >= 2) {
|
|
|
+ try {
|
|
|
+ int second = Integer.parseInt(parts[1]);
|
|
|
+ if (second >= 16 && second <= 31) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|