一次由特殊字符引发的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 {@Overridepublic 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: 20230815T032345ZUNSIGNED-PAYLOAD
成功请求对比:
[DEBUG] Canonical Request:
PUT
/testbucket/正常文件%20名称.docx
Host: minio.example.com
Content-Type: application/octet-stream
X-Amz-Date: 20230815T032400ZUNSIGNED-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)
-
特殊空格场景签名差异显著(测试用例2)
-
差异源于请求路径的编码处理方式不同
字符编码分析
从上面分析中,基本锁定是文件名称编码问题,现在分析下具体编码哪里出问题了
诊断代码:
//文件名十六进制解析
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+0020String specialSpace = "test\u00A0file"; // U+00A0System.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());