当前位置: 首页 > news >正文

一次由特殊字符引发的Minio签名问题排查

一、背景

测试反馈批量上传大量文件(pdf文件,大小在1-5M)左右,总会出现有文件上传失败情况。。近期线上环境突然出现文件上传失败的问题,错误日志显示:

Caused by: io.minio.errors.ErrorResponseException: The request signature we calculated does not match the signature you provided. Check your key and signing method.
	at io.minio.S3Base$1.onResponse(S3Base.java:775)
	at okhttp3.internal.connection.RealCall$AsyncCall.run(RealCall.kt:519)
	... 3 common frames omitted

该错误提示签名校验失败,但相同的客户端代码在其他环境运行正常,且上传成功率并非100%失败。

先说下结论:文件名有U+00A0编码空格,导致上传时客户端签名校验失败

二、排查过程

上面的报错信息很明确,客户端计算的签名和代码请求携带的签名不一致,请检查秘钥和签名方法。一开始就是从报错信息思路去排查

1、基础环境验证

因为有文件上传成功,那么我们的minio的配置和网络层没啥问题

2、签名排查

那只有请求参数可能有问题了,我们捕获请求参数

捕获请求日志

日志增强代码

// 自定义请求监听器
class SignatureDebugger implements RequestListener {
    @Override
    public void beforeRequest(HttpRequest request) {
        String method = request.method();
        String path = request.uri().getRawPath();
        String headers = request.headers().toString();
        
        System.out.println("[DEBUG] Canonical Request:\n" +
            method + "\n" +
            path + "\n" +
            headers + "\n" +
            "UNSIGNED-PAYLOAD");
    }
}

// 客户端配置
MinioClient client = MinioClient.builder()
    .endpoint("https://minio.example.com")
    .credentials(accessKey, secretKey)
    .requestListener(new SignatureDebugger())
    .build();

失败请求输出

[DEBUG] Canonical Request:
PUT
/testbucket/季度报告 2023.docx
Host: minio.example.com
Content-Type: application/octet-stream
X-Amz-Date: 20230815T032345Z

UNSIGNED-PAYLOAD

成功请求对比

[DEBUG] Canonical Request:
PUT
/testbucket/正常文件%20名称.docx
Host: minio.example.com
Content-Type: application/octet-stream
X-Amz-Date: 20230815T032400Z

UNSIGNED-PAYLOAD

关键发现

  • 失败请求路径包含未编码的U+00A0字符(显示为空格)

  • 成功请求路径包含编码后的%20

手动生成签名对比

测试工具类

public class SignatureComparator {
    // 手动生成签名
    public static String manualSign(String objectName) throws Exception {
        AWSCredentials credentials = new BasicAWSCredentials("AKIAEXAMPLE", "s3cr3tK3y");
        AWS4Signer signer = new AWS4Signer();
        signer.setServiceName("s3");
        signer.setRegionName("cn-north-1");

        Request<?> request = new DefaultRequest<>("s3");
        request.setHttpMethod(HttpMethodName.PUT);
        request.setEndpoint(URI.create("https://minio.example.com"));
        request.setResourcePath(objectName);

        request.addHeader("Host", "minio.example.com");
        request.addHeader("X-Amz-Date", "20230815T032345Z");
        request.addHeader("Content-Type", "application/octet-stream");

        signer.sign(request, credentials);
        return request.getHeaders().get("Authorization");
    }

    // 获取客户端实际签名
    public static String captureClientSignature(String objectName) throws Exception {
        MinioClient client = MinioClient.builder()
            .endpoint("https://minio.example.com")
            .credentials("AKIAEXAMPLE", "s3cr3tK3y")
            .build();

        try {
            client.putObject(PutObjectArgs.builder()
                .bucket("testbucket")
                .object(objectName)
                .stream(new ByteArrayInputStream(new byte[0]), 0, -1)
                .build());
        } catch (ErrorResponseException e) {
            return e.response().headers().get("Authorization");
        }
        return null;
    }
}

对比测试用例

public static void main(String[] args) throws Exception {
    // 测试用例1:普通空格
    String normalName = "test file.txt";
    System.out.println("普通空格签名对比:");
    System.out.println("手动签名: " + manualSign("/testbucket/" + normalName));
    System.out.println("客户端签名: " + captureClientSignature(normalName));

    // 测试用例2:U+00A0空格
    String specialName = "test\u00A0file.txt";
    System.out.println("\n特殊空格签名对比:");
    System.out.println("手动签名: " + manualSign("/testbucket/" + specialName));
    System.out.println("客户端签名: " + captureClientSignature(specialName));
}

输出结果

普通空格签名对比:
手动签名: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20230815/cn-north-1/s3/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=6d8f1c...
客户端签名: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20230815/cn-north-1/s3/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=6d8f1c...

特殊空格签名对比:
手动签名: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20230815/cn-north-1/s3/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=8a3bcd...
客户端签名: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20230815/cn-north-1/s3/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=7e92fa...

关键结论

  1. 普通空格场景签名一致(测试用例1)

  2. 特殊空格场景签名差异显著(测试用例2)

  3. 差异源于请求路径的编码处理方式不同

字符编码分析

从上面分析中,基本锁定是文件名称编码问题,现在分析下具体编码哪里出问题了

诊断代码:

//文件名十六进制解析
public class HexAnalyzer {
    public static void printHex(String filename) {
        byte[] bytes = filename.getBytes(StandardCharsets.UTF_8);
        System.out.println(HexFormat.of().formatHex(bytes));
    }

    public static void main(String[] args) {
        String normalSpace = "test file";    // U+0020
        String specialSpace = "test\u00A0file"; // U+00A0
        
        System.out.println("普通空格文件名字节:");
        printHex(normalSpace);
        
        System.out.println("\n特殊空格文件名字节:");
        printHex(specialSpace);
    }
}

输出结果

普通空格文件名字节:
74 65 73 74 20 66 69 6c 65

特殊空格文件名字节:
74 65 73 74 c2 a0 66 69 6c 65

编码差异

  • 普通空格:单字节0x20

  • U+00A0空格:双字节0xC2 0xA0

原因定位

签名生成机制差异

对比维度客户端实际行为服务端预期行为
路径编码规则直接使用原始字符要求RFC 3986百分号编码
空格处理保留U+00A0字符期望转换为%C2%A0
签名计算基准未编码路径已编码路径

示例对比

// 客户端实际参与签名的路径
String clientSignPath = "/testbucket/季度报告 2023.docx";

// 服务端期望的签名路径
String serverExpectPath = "/testbucket/季度报告%C2%A02023.docx";

结论

  • 客户端未对U+00A0进行正确编码

  • 服务端接收时自动解码得到U+00A0字符

  • 两端计算的规范请求出现差异导致签名不匹配

三、修复方案

统一编码处理

public class UriEncoder {
    public static String encodePath(String path) {
        try {
            URI uri = new URI(null, null, path, null);
            return uri.getRawPath();
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException("Invalid path: " + path, e);
        }
    }
}

// 修复后上传逻辑
String rawFileName = "季度报告 2023.docx"; // 包含U+00A0
String encodedPath = UriEncoder.encodePath(rawFileName);

minioClient.putObject(PutObjectArgs.builder()
    .bucket("testbucket")
    .object(encodedPath) // 转换为"季度报告%C2%A02023.docx"
    .stream(inputStream, -1, 10485760)
    .build());

相关文章:

  • Docker多阶段构建:告别臃肿镜像的终极方案
  • git上传大文件到远程仓库中
  • 工作杂谈(十七)——研发阶段术语
  • 死亡并不是走出生命 而是走出时间
  • Xyz坐标系任意两个面之间投影转换方法
  • 基于vue.js开发的家庭装修管理系统开发与设计(源码+lw+部署文档+讲解),源码可白嫖!
  • 写作软件新体验:让文字创作更高效
  • Python:进程介绍及语法结构
  • 707.设计链表
  • 硬件基础--03_电流
  • 国央企如何识别并防范虚假贸易?
  • G 2024hubei province 学习到的内容
  • 重温Mqtt
  • Java试题
  • 关于金碟K3,禁用和启用需要流程审批后执行
  • 利用GitHub Pages快速部署前端框架静态网页
  • Chrome(Google) 浏览器安装Vue2、Vue3 Devtools插件方法
  • 工作记录 2017-03-07
  • java泛型的协变、逆变和不变
  • 3、pytest实现参数化
  • 政府建设网站申请/应用市场
  • 哪个网站能把图片拼凑起来做gif的/做网站的步骤
  • 没有做防注入的网站/泉州搜索推广
  • css div网站/加速游戏流畅的软件
  • 三个字的洋气商标名字/搜索引擎优化的主要策略
  • 浙江五联建设有限公司网站/深圳百度推广客服电话多少