最近是不是经常发现自己网站的图片资源莫名其妙地出现在别人的网站上?而这些图片却是存储在你自己的服务器,消耗着你的带宽资源!更糟的是,当别人网站加载缓慢时,用户可能会误以为是你的网站出了问题。作为开发者,我们需要一种有效的方式来保护自己的图片资源,这就是图片防盗链技术的意义所在。

什么是图片防盗链?

图片防盗链是一种保护网站图片资源不被其他网站直接引用的技术手段。当用户访问网页时,浏览器会发送包含 Referer 信息的 HTTP 请求,这个 Referer 记录了请求来源页面的 URL。

sequenceDiagram
    participant 用户浏览器
    participant 正常网站A
    participant 盗链网站B
    participant 图片服务器

    用户浏览器->>正常网站A: 访问网站A
    正常网站A->>用户浏览器: 返回HTML(包含图片链接)
    用户浏览器->>图片服务器: 请求图片(Referer:网站A)
    图片服务器->>用户浏览器: 返回图片资源

    用户浏览器->>盗链网站B: 访问网站B
    盗链网站B->>用户浏览器: 返回HTML(包含网站A的图片链接)
    用户浏览器->>图片服务器: 请求图片(Referer:网站B)
    图片服务器-->>用户浏览器: 拒绝请求(检测到非法Referer)

防盗链的原理就是通过检查 HTTP 请求头中的 Referer 字段,判断请求是否来自允许的域名。如果不是,则拒绝提供服务。

为什么需要实现图片防盗链?

  1. 节省带宽资源:图片被他人网站引用时,消耗的是你自己服务器的带宽资源
  2. 保护版权:避免原创图片被随意使用
  3. 控制访问来源:确保资源只被允许的网站使用
  4. 防止资源滥用:避免恶意网站大量引用导致服务器负载过高

SpringBoot 实现图片防盗链方案

在 SpringBoot 中实现图片防盗链,我选择使用过滤器(Filter)方式,因为它能在请求到达控制器前拦截并处理,适合处理跨域请求和防盗链需求。

实现思路

flowchart TD
    A[HTTP请求] --> B{过滤器}
    B -->|Referer合法| C[放行请求]
    B -->|Referer不合法| D[拒绝请求]
    B -->|无Referer但允许| C
    B -->|请求非图片资源| C
    B -->|资源在白名单中| C
    C --> E[Controller处理]
    D --> F[返回错误信息或默认图片]

更详细的组件协作流程:

sequenceDiagram
    participant 浏览器
    participant 过滤器
    participant RefererChecker
    participant 缓存
    participant 策略工厂

    浏览器->>过滤器: HTTP GET /image.jpg
    过滤器->>ResourceTypeChecker: 检查是否为图片资源
    ResourceTypeChecker-->>过滤器: 是(.jpg)
    过滤器->>RefererChecker: 获取Referer
    RefererChecker->>缓存: 检查缓存
    缓存-->>RefererChecker: 未命中,检查域名
    RefererChecker-->>过滤器: 非法Referer
    过滤器->>策略工厂: 获取拒绝策略
    策略工厂-->>过滤器: DefaultImageStrategy
    过滤器->>浏览器: 返回默认图片(200 OK)

项目实现步骤

1. 创建 SpringBoot 项目

首先创建一个基础的 SpringBoot 项目,添加必要的依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <!-- 添加缓存支持 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>
</dependencies>

2. 添加配置参数

application.yml中添加防盗链配置:

# 防盗链配置
anti-hotlink:
  # 是否启用防盗链
  enabled: true
  # 允许的域名列表(支持精确匹配、子域名匹配和正则表达式)
  allowed-domains:
    - localhost
    - 127.0.0.1
    - example.com
    - "*.example.com"
    - "^test\\d+\\.domain\\.com$"  # 正则表达式,匹配test1.domain.com等
    - "^sub\\.example\\.com$"      # 匹配sub.example.com(YAML中反斜杠需双重转义)
  # 需要保护的资源格式
  protected-formats:
    - .jpg
    - .jpeg
    - .png
    - .gif
    - .bmp
    - .webp
  # 是否允许直接访问(无Referer)
  allow-direct-access: true
  # 拒绝访问时的动作:REDIRECT, FORBIDDEN, DEFAULT_IMAGE
  deny-action: DEFAULT_IMAGE
  # 默认图片路径(当deny-action为DEFAULT_IMAGE时使用)
  default-image: /images/no-hotlinking.png
  # 白名单路径(不需要防盗链检查的路径)
  whitelist-paths:
    - /api/public/**
    - /images/public/**
  # 缓存配置
  cache:
    expire-after-write: 300  # 缓存有效期(秒)
    maximum-size: 1000       # 最大缓存条目数
  # 签名URL配置
  signer:
    secret-key: ${SECURE_SIGNER_KEY}  # 从环境变量或配置中心获取
    tolerance-seconds: 60  # 时间容差(秒)

3. 定义拒绝动作枚举

使用枚举替代字符串类型,提高代码类型安全性:

package com.example.antihotlink.config;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum DenyAction {
    REDIRECT("redirect"),
    FORBIDDEN("forbidden"),
    DEFAULT_IMAGE("default");

    private final String value;

    public static DenyAction fromValue(String value) {
        for (DenyAction action : values()) {
            if (action.getValue().equals(value)) {
                return action;
            }
        }
        return DEFAULT_IMAGE; // 默认值
    }
}

4. 创建配置类读取配置

package com.example.antihotlink.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Data
@Component
@ConfigurationProperties(prefix = "anti-hotlink")
public class AntiHotlinkProperties {
    /**
     * 是否启用防盗链
     */
    private boolean enabled = true;

    /**
     * 允许的域名列表
     */
    private List<String> allowedDomains = new ArrayList<>();

    /**
     * 需要保护的资源格式
     */
    private List<String> protectedFormats = new ArrayList<>();

    /**
     * 是否允许直接访问(无Referer)
     */
    private boolean allowDirectAccess = true;

    /**
     * 拒绝访问时的动作
     */
    private DenyAction denyAction = DenyAction.DEFAULT_IMAGE;

    /**
     * 默认图片路径
     */
    private String defaultImage = "/images/no-hotlinking.png";

    /**
     * 白名单路径
     */
    private List<String> whitelistPaths = new ArrayList<>();

    /**
     * 缓存配置
     */
    @Data
    public static class CacheConfig {
        /**
         * 缓存过期时间(秒)
         */
        private int expireAfterWrite = 300;

        /**
         * 最大缓存条目数
         */
        private int maximumSize = 1000;
    }

    private CacheConfig cache = new CacheConfig();

    /**
     * 签名URL配置
     */
    @Data
    public static class SignerConfig {
        /**
         * 签名密钥
         */
        private String secretKey;

        /**
         * 时间容差(秒)
         */
        private int toleranceSeconds = 60;
    }

    private SignerConfig signer = new SignerConfig();
}

5. 配置缓存管理器

package com.example.antihotlink.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class CacheConfig {

    private final AntiHotlinkProperties properties;

    public CacheConfig(AntiHotlinkProperties properties) {
        this.properties = properties;
    }

    @Bean
    public CacheManager antiHotlinkCacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("allowedReferers");
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .expireAfterWrite(properties.getCache().getExpireAfterWrite(), TimeUnit.SECONDS)
                .maximumSize(properties.getCache().getMaximumSize())
                .recordStats()); // 启用统计信息收集,用于监控
        return cacheManager;
    }
}

6. 创建拒绝策略接口和实现

按照单一职责原则,将拒绝处理逻辑分离为独立策略:

package com.example.antihotlink.strategy;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public interface DenyActionStrategy {
    /**
     * 处理拒绝访问请求
     */
    void handle(HttpServletRequest request, HttpServletResponse response) throws IOException;
}

实现几种拒绝策略:

package com.example.antihotlink.strategy.impl;

import com.example.antihotlink.strategy.DenyActionStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@Component("forbiddenStrategy")
public class ForbiddenStrategy implements DenyActionStrategy {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.getWriter().write("Forbidden: Direct linking to this resource is not allowed");
    }
}
package com.example.antihotlink.strategy.impl;

import com.example.antihotlink.strategy.DenyActionStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@Component("redirectStrategy")
public class RedirectStrategy implements DenyActionStrategy {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.sendRedirect("/");
    }
}
package com.example.antihotlink.strategy.impl;

import com.example.antihotlink.config.AntiHotlinkProperties;
import com.example.antihotlink.strategy.DenyActionStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;

@Slf4j
@Component("defaultImageStrategy")
@RequiredArgsConstructor
public class DefaultImageStrategy implements DenyActionStrategy {

    private final AntiHotlinkProperties properties;
    private final ResourceLoader resourceLoader;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            String defaultImagePath = properties.getDefaultImage();
            // 根据资源路径确定加载位置,避免硬编码路径前缀
            String resourcePath;
            if (defaultImagePath.startsWith("/")) {
                resourcePath = "classpath:static" + defaultImagePath;
            } else {
                resourcePath = "classpath:" + defaultImagePath;
            }

            Resource resource = resourceLoader.getResource(resourcePath);
            if (!resource.exists()) {
                log.error("默认图片资源不存在: {}", resourcePath);
                response.setStatus(HttpStatus.NOT_FOUND.value());
                return;
            }

            // 根据文件扩展名动态设置MIME类型,避免硬编码
            MediaType mediaType = MediaTypeFactory.getMediaTypeForFileName(defaultImagePath)
                    .orElse(MediaType.IMAGE_JPEG); // 设置更精准的默认值
            response.setContentType(mediaType.toString());

            try (InputStream in = resource.getInputStream()) {
                StreamUtils.copy(in, response.getOutputStream());
            }
        } catch (Exception e) {
            log.error("发送默认图片时出错: {}", e.getMessage(), e);
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        }
    }
}

7. 创建策略工厂

package com.example.antihotlink.strategy;

import com.example.antihotlink.config.DenyAction;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;

@Component
@RequiredArgsConstructor
public class DenyActionStrategyFactory {

    private final DenyActionStrategy defaultImageStrategy;
    private final DenyActionStrategy forbiddenStrategy;
    private final DenyActionStrategy redirectStrategy;

    private final Map<DenyAction, DenyActionStrategy> strategies = new HashMap<>();

    @PostConstruct
    public void init() {
        strategies.put(DenyAction.DEFAULT_IMAGE, defaultImageStrategy);
        strategies.put(DenyAction.FORBIDDEN, forbiddenStrategy);
        strategies.put(DenyAction.REDIRECT, redirectStrategy);
    }

    public DenyActionStrategy getStrategy(DenyAction action) {
        return strategies.getOrDefault(action, defaultImageStrategy);
    }
}

8. 创建 Referer 检查器组件

package com.example.antihotlink.service;

import com.example.antihotlink.config.AntiHotlinkProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

@Slf4j
@Component
@RequiredArgsConstructor
public class RefererChecker {

    private final AntiHotlinkProperties properties;
    // 预编译正则表达式,提高性能
    private final Map<String, Pattern> regexPatterns = new HashMap<>();

    @PostConstruct
    public void init() {
        // 初始化时预编译所有正则表达式
        properties.getAllowedDomains().stream()
                .filter(domain -> domain.startsWith("^"))
                .forEach(domain -> {
                    try {
                        regexPatterns.put(domain, Pattern.compile(domain));
                    } catch (PatternSyntaxException e) {
                        log.error("跳过无效正则规则: {},错误: {}", domain, e.getMessage());
                        // 可以在此添加监控指标或报警通知
                    }
                });
        log.info("预编译正则表达式完成,共 {} 个模式", regexPatterns.size());
    }

    /**
     * 检查Referer是否允许访问
     * 使用缓存优化性能
     */
    @Cacheable(value = "allowedReferers", key = "#referer", cacheManager = "antiHotlinkCacheManager")
    public boolean isAllowedReferer(String referer) {
        if (referer == null || referer.isEmpty()) {
            return properties.isAllowDirectAccess();
        }

        try {
            URL refererUrl = new URL(referer);
            String refererHost = refererUrl.getHost();
            return isAllowedRefererHost(refererHost);
        } catch (MalformedURLException e) {
            log.warn("非法Referer格式: {}", referer, e);
            return false; // 非法格式拒绝访问,而不是直接放行
        }
    }

    /**
     * 检查Referer域名是否在允许列表中
     */
    private boolean isAllowedRefererHost(String refererHost) {
        for (String domain : properties.getAllowedDomains()) {
            // 精确匹配
            if (domain.equals(refererHost)) {
                return true;
            }

            // 通配符匹配(*.example.com)
            if (domain.startsWith("*.") && refererHost.endsWith(domain.substring(1))) {
                return true;
            }

            // 正则表达式匹配 - 使用预编译的Pattern
            if (domain.startsWith("^") && regexPatterns.containsKey(domain)) {
                Pattern pattern = regexPatterns.get(domain);
                if (pattern.matcher(refererHost).matches()) {
                    return true;
                }
            }
        }
        return false;
    }
}

9. 创建资源类型检查器

package com.example.antihotlink.service;

import com.example.antihotlink.config.AntiHotlinkProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;

@Slf4j
@Component
@RequiredArgsConstructor
public class ResourceTypeChecker {

    private final AntiHotlinkProperties properties;
    private final AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 检查请求的资源是否受保护
     */
    public boolean isProtectedResource(String requestUri) {
        // 检查是否在白名单路径中
        if (isWhitelistedPath(requestUri)) {
            return false;
        }

        // 检查是否是受保护的文件格式
        String lowerUri = requestUri.toLowerCase();
        return properties.getProtectedFormats().stream()
                .map(String::toLowerCase) // 确保格式比较忽略大小写
                .anyMatch(lowerUri::endsWith);
    }

    /**
     * 检查请求路径是否在白名单中
     */
    private boolean isWhitelistedPath(String requestUri) {
        return properties.getWhitelistPaths().stream()
                .anyMatch(pattern -> pathMatcher.match(pattern, requestUri));
    }
}

10. 优化的防盗链过滤器

package com.example.antihotlink.filter;

import com.example.antihotlink.config.AntiHotlinkProperties;
import com.example.antihotlink.service.RefererChecker;
import com.example.antihotlink.service.ResourceTypeChecker;
import com.example.antihotlink.strategy.DenyActionStrategyFactory;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URL;

@Slf4j
@Component
@RequiredArgsConstructor
public class AntiHotlinkFilter extends OncePerRequestFilter {

    private final AntiHotlinkProperties properties;
    private final ResourceTypeChecker resourceTypeChecker;
    private final RefererChecker refererChecker;
    private final DenyActionStrategyFactory strategyFactory;
    private final MeterRegistry meterRegistry; // 用于性能监控

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // 如果防盗链未启用,直接放行
        if (!properties.isEnabled()) {
            filterChain.doFilter(request, response);
            return;
        }

        String requestUri = request.getRequestURI();

        // 检查是否是受保护的资源格式
        boolean isProtectedResource = resourceTypeChecker.isProtectedResource(requestUri);
        if (!isProtectedResource) {
            filterChain.doFilter(request, response);
            return;
        }

        // 获取Referer并检查
        String referer = request.getHeader("Referer");
        boolean isAllowed = refererChecker.isAllowedReferer(referer);

        if (isAllowed) {
            // 记录通过的请求指标
            meterRegistry.counter("anti.hotlink.allowed",
                "resource_type", getResourceType(requestUri)).increment();
            filterChain.doFilter(request, response);
        } else {
            // 记录被拦截的请求指标
            meterRegistry.counter("anti.hotlink.blocked",
                "referer_domain", getDomain(referer),
                "resource_type", getResourceType(requestUri)).increment();

            log.info("检测到图片盗链: {} -> {}", referer, requestUri);
            // 使用策略模式处理拒绝访问
            strategyFactory.getStrategy(properties.getDenyAction())
                    .handle(request, response);
        }
    }

    // 获取资源类型,用于统计
    private String getResourceType(String uri) {
        int lastDotIndex = uri.lastIndexOf('.');
        if (lastDotIndex > 0 && lastDotIndex < uri.length() - 1) {
            return uri.substring(lastDotIndex).toLowerCase();
        }
        return "unknown";
    }

    // 从Referer中提取域名
    private String getDomain(String referer) {
        if (referer == null || referer.isEmpty()) {
            return "no-referer";
        }

        try {
            URL url = new URL(referer);
            return url.getHost();
        } catch (Exception e) {
            return "invalid-referer";
        }
    }
}

11. 添加签名 URL 支持

package com.example.antihotlink.util;

import com.example.antihotlink.config.AntiHotlinkProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Base64;

@Slf4j
@Component
@RequiredArgsConstructor
public class UrlSigner {

    private static final String HMAC_ALGORITHM = "HmacSHA256";
    private final AntiHotlinkProperties properties;

    /**
     * 生成带签名的URL
     * @param path 图片路径
     * @param expireSeconds 有效期秒数
     * @return 带签名的URL
     */
    public String generateSignedUrl(String path, long expireSeconds) {
        Instant expireAt = Instant.now().plusSeconds(expireSeconds);
        long expireTimestamp = expireAt.getEpochSecond();

        String dataToSign = path + ":" + expireTimestamp;
        String signature = generateHmac(dataToSign);

        return path + "?expires=" + expireTimestamp + "&signature=" + signature;
    }

    /**
     * 验证URL签名
     * @param path 图片路径
     * @param expires 过期时间戳
     * @param signature 签名
     * @return 是否有效
     */
    public boolean verifySignedUrl(String path, long expires, String signature) {
        return verifySignedUrl(path, expires, signature, properties.getSigner().getToleranceSeconds());
    }

    /**
     * 验证URL签名(带时间容差)
     * @param path 图片路径
     * @param expires 过期时间戳
     * @param signature 签名
     * @param toleranceSeconds
     * @return 是否有效
     */
    public boolean verifySignedUrl(String path, long expires, String signature, long toleranceSeconds) {
        // 检查是否已过期,考虑时间容差
        if (Instant.now().plusSeconds(toleranceSeconds).getEpochSecond() > expires) {
            return false;
        }

        String dataToSign = path + ":" + expires;
        String expectedSignature = generateHmac(dataToSign);

        return expectedSignature.equals(signature);
    }

    private String generateHmac(String data) {
        try {
            String secretKey = properties.getSigner().getSecretKey();
            if (secretKey == null || secretKey.isEmpty()) {
                throw new IllegalStateException("签名密钥未配置");
            }

            Mac hmac = Mac.getInstance(HMAC_ALGORITHM);
            SecretKeySpec secretKeySpec = new SecretKeySpec(
                    secretKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
            hmac.init(secretKeySpec);
            byte[] hmacBytes = hmac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return Base64.getUrlEncoder().withoutPadding().encodeToString(hmacBytes);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            log.error("生成HMAC签名失败", e);
            throw new RuntimeException("签名失败", e);
        }
    }
}

使用示例:

@RestController
@RequiredArgsConstructor
public class ImageUrlController {

    private final UrlSigner urlSigner;

    @GetMapping("/generate-image-url")
    public Map<String, String> generateImageUrl(@RequestParam String imagePath) {
        // 生成24小时有效的签名URL
        String signedUrl = urlSigner.generateSignedUrl(imagePath, 86400);
        return Map.of("url", signedUrl);
    }
}

域名匹配规则说明

在配置防盗链允许域名时,支持多种匹配方式:

配置项匹配示例说明
localhostlocalhostlocalhost:8080精确匹配域名部分(忽略端口)
*.example.comsub.example.comdeep.sub.example.com子域名匹配(匹配任意层级子域名)
^test\\d+\\.com$test1.comtest2.com正则表达式匹配(注意需要对特殊字符转义)
example.orgexample.org,但不匹配subdomain.example.org精确匹配根域名

注意:

  • 正则表达式在 YAML 中需要双重转义(反斜杠自身需要转义)
  • 端口号不参与域名匹配判断
  • 匹配规则按配置顺序检查,命中任一规则即通过

生产环境部署注意事项

CDN 与反向代理兼容

使用 CDN 或反向代理时需要特别注意:

  1. Nginx 配置转发 Referer:
proxy_set_header Referer $http_referer;
  1. HTTPS 与 HTTP 混合使用:
    若网站同时支持 HTTP 和 HTTPS,需要注意 Referer 的协议头差异。来自 HTTPS 页面的请求访问 HTTP 资源时,浏览器可能不发送 Referer,或者只发送域名部分。
  2. 多级代理场景:
    多级代理可能会丢失原始 Referer,此时可使用X-Forwarded-Host等自定义头部。

CDN 与应用层防盗链配合

采用分层防护策略效果最佳:

  1. CDN 边缘层防盗链 - 第一道防线:
  • 配置 Referer 白名单和 User-Agent 限制
  • 靠近用户,减少无效流量传输,降低源站压力
  • 示例: 阿里云 OSS 设置 Referer 白名单并禁止空 Referer
  1. 云存储防盗链配置:
  • 在云存储控制台配置 Referer 白名单,作为第一层防护
  • 应用层过滤器作为补充,处理未被云存储拦截的请求
  • 敏感资源优先使用云存储的签名 URL(如 AWS S3 的 Presigned URL)
  1. 应用层防盗链 - 第二道防线:
  • 处理复杂验证逻辑和精细控制
  • 与用户认证系统结合,限制资源访问权限

防 Referer 欺骗

Referer 可以被伪造,建议多重保护:

  1. 签名 URL: 添加时间限制和唯一性验证
  2. 结合用户认证: 敏感资源检查登录状态
  3. 限流防护: 对可疑 IP 实施请求频率限制

跨域与 CORS

若需要支持第三方合法调用,需配置 CORS:

@Bean
public WebMvcConfigurer corsConfigurer() {
    return new WebMvcConfigurer() {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/images/**")
                .allowedOrigins("https://partner.example.com")
                .allowedMethods("GET")
                .maxAge(3600);
        }
    };
}

性能监控与缓存优化

高并发环境下的监控建议:

  1. 缓存命中率监控:
@Autowired
private CaffeineCache allowedReferersCache;

// 每分钟记录缓存统计
@Scheduled(fixedRate = 60000)
public void reportCacheStats() {
    log.info("防盗链缓存统计: 命中率={}, 加载时间={}ms",
        allowedReferersCache.getNativeCache().stats().hitRate(),
        allowedReferersCache.getNativeCache().stats().averageLoadPenalty()/1000000);
}
  1. Micrometer 指标收集:
// 监控缓存命中率
meterRegistry.gauge("anti.hotlink.cache.hitRate",
    allowedReferersCache.getNativeCache().stats(), stats -> stats.hitRate());
  1. 异步日志配置:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
  <appender-ref ref="FILE" />
  <queueSize>512</queueSize>
  <discardingThreshold>0</discardingThreshold>
</appender>

常见问题

Q:为什么配置了允许域名仍被拦截?

A:可能原因:

  1. Referer URL 格式不正确(需包含协议头)
  2. 域名大小写不匹配
  3. 带端口的域名配置问题
  4. 反向代理修改/丢失了原始 Referer

Q:移动端 App 请求无 Referer 如何处理?

A:解决方案:

  1. 开启allow-direct-access配置
  2. 要求 App 添加自定义认证头(如X-App-Token
  3. 为 App 提供签名 URL 访问机制

Q:浏览器隐私模式适配问题?

A:部分浏览器(如 Chrome 隐身模式、Firefox 私密浏览)可能不发送 Referer,或发送no-referrer。处理方法:

  1. 配置allow-direct-access=true
  2. 结合 Cookie 验证替代 Referer 检查
  3. 提供备用认证机制(如 URL 参数令牌)

Q:如何处理图片在社交媒体分享?

A:社交平台抓取图片预览的处理:

  1. 将社交平台域名添加到白名单
  2. 创建专用预览图片路径并加入白名单
  3. 提供低分辨率预览版本开放访问

优化与扩展

1. 动态配置刷新

@RefreshScope // 结合Spring Cloud Config使用
@Component
@ConfigurationProperties(prefix = "anti-hotlink")
public class AntiHotlinkProperties {
    // 属性配置...
}

或实现定时刷新:

@Scheduled(fixedRateString = "${anti-hotlink.config-refresh-interval:60000}")
public void refreshConfig() {
    // 从数据库或配置中心重新加载配置
    allowedDomains = domainRepository.findAllAllowedDomains();
    // 刷新预编译正则表达式
    refreshRegexPatterns();
}

2. 多策略组合防御

flowchart TD
    A["HTTP请求"] --> B{"防盗链过滤器"}
    B --> C{"Referer检查"} & D{"签名验证"} & E{"访问频率检查"} & F{"用户认证"}
    C -- 通过 --> G["放行"]
    D -- 通过 --> G
    E -- 通过 --> G
    F -- 通过 --> G
    C -- 不通过 --> H["拒绝"]
    D -- 不通过 --> H
    E -- 不通过 --> H
    F -- 不通过 --> H

    linkStyle 5 stroke:#D50000,fill:none
    linkStyle 6 stroke:#FFD600,fill:none
    linkStyle 7 stroke:#00C853,fill:none
    linkStyle 8 stroke:#2962FF,fill:none
    linkStyle 9 stroke:#D50000,fill:none
    linkStyle 10 stroke:#FFD600,fill:none
    linkStyle 11 stroke:#00C853,fill:none
    linkStyle 12 stroke:#2962FF

实现组合策略链:

public class CompositeProtectionChain {
    private final List<ProtectionStrategy> strategies;

    // 按顺序执行所有策略
    public boolean check(HttpServletRequest request) {
        return strategies.stream()
                .allMatch(strategy -> strategy.check(request));
    }
}

3. 水印与追踪机制

对图片添加动态水印,便于追踪泄露源:

@GetMapping("/images/{filename:.+}")
public void getImageWithWatermark(
        @PathVariable String filename,
        HttpServletRequest request,
        HttpServletResponse response) throws IOException {

    // 读取原始图片
    Resource imageResource = resourceLoader.getResource("classpath:images/" + filename);
    BufferedImage originalImage = ImageIO.read(imageResource.getInputStream());

    // 添加水印(如用户ID或请求IP)
    String watermarkText = request.getRemoteAddr();
    BufferedImage watermarkedImage = addWatermark(originalImage, watermarkText);

    // 输出图片
    response.setContentType(MediaType.IMAGE_JPEG_VALUE);
    ImageIO.write(watermarkedImage, "jpg", response.getOutputStream());
}

性能对比

不同优化手段对防盗链系统性能的影响:

优化手段内存占用响应延迟拦截吞吐量适用场景
无优化基础版~800 req/s小型站点,流量较低
本地缓存~5000 req/s中大型站点,重复访问多
正则预编译~2000 req/s使用复杂域名匹配规则
异步日志~4800 req/s高并发请求,日志量大
全量优化~8000 req/s企业级应用,要求高吞吐

注:数据基于 4 核 8G 内存测试环境,实际性能因环境而异

总结

功能实现方式优点缺点
图片防盗链分层组件实现 Referer 检查模块化设计,易于扩展Referer 可能被伪造或屏蔽
配置灵活性YAML 配置+动态刷新无需重启更新规则增加系统复杂度
资源分类保护白名单路径+格式过滤精细化权限控制配置维护成本增加
策略模式处理拒绝策略接口+工厂易于扩展新的拒绝策略需额外创建多个类
性能优化缓存+异步日志+正则预编译显著提升吞吐量占用额外内存资源
安全增强签名 URL+水印+时间容差多层次保护,追踪泄露源实现复杂度高

异常君
1 声望2 粉丝

在 Java 的世界里,永远有下一座技术高峰等着你。我愿做你登山路上的同频伙伴,陪你从看懂代码到写出让自己骄傲的代码。咱们,代码里见!