Files
seaweedfs/weed/s3api/chunked_reader_v4.go
T
Chris LuandGitHub 16717b0bf4 fix(s3): authenticate JWT unsigned-streaming uploads (#9729)
A bearer-token client whose SDK appends a CRC32 trailer sends an
unsigned-streaming PUT (STREAMING-UNSIGNED-PAYLOAD-TRAILER) with no SigV4
signature, so getRequestAuthType classifies it as authTypeStreamingUnsigned.
The auth dispatch ignored the bearer token and fell back to anonymous, and
newChunkedReader tried to verify the bearer token as a SigV4 seed signature
and failed, so the body could not be decoded either.

Dispatch the streaming-unsigned auth on whatever credential is present
(SigV4 / JWT / anonymous), and skip the SigV4 seed-signature recompute for
JWT requests in the chunked reader.
2026-05-28 18:10:24 -07:00

619 lines
19 KiB
Go

package s3api
// the related code is copied and modified from minio source code
/*
* Minio Cloud Storage, (C) 2016 Minio, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import (
"bufio"
"bytes"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"hash"
"hash/crc32"
"io"
"net/http"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/dustin/go-humanize"
"github.com/minio/crc64nvme"
)
// calculateSeedSignature - Calculate seed signature in accordance with
// - http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html
//
// returns signature, error otherwise if the signature mismatches or any other
// error while parsing and validating.
func (iam *IdentityAccessManagement) calculateSeedSignature(r *http.Request) (cred *Credential, signature string, region string, service string, date time.Time, errCode s3err.ErrorCode) {
_, credential, calculatedSignature, authInfo, errCode := iam.verifyV4Signature(r, true)
if errCode != s3err.ErrNone {
return nil, "", "", "", time.Time{}, errCode
}
// This check ensures we only proceed for streaming uploads.
switch authInfo.HashedPayload {
case streamingContentSHA256, streamingContentSHA256Trailer:
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", authInfo.HashedPayload == streamingContentSHA256Trailer)
case streamingUnsignedPayload:
glog.V(3).Infof("streaming unsigned payload")
default:
return nil, "", "", "", time.Time{}, s3err.ErrContentSHA256Mismatch
}
return credential, calculatedSignature, authInfo.Region, authInfo.Service, authInfo.Date, s3err.ErrNone
}
const maxLineLength = 4 * humanize.KiByte // assumed <= bufio.defaultBufSize 4KiB
// lineTooLong is generated as chunk header is bigger than 4KiB.
var errLineTooLong = errors.New("header line too long")
// Malformed encoding is generated when chunk header is wrongly formed.
var errMalformedEncoding = errors.New("malformed chunked encoding")
// newChunkedReader returns a new s3ChunkedReader that translates the data read from r
// out of HTTP "chunked" format before returning it.
// The s3ChunkedReader returns io.EOF when the final 0-length chunk is read.
func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.ReadCloser, s3err.ErrorCode) {
glog.V(3).Infof("creating a new newSignV4ChunkedReader")
contentSha256Header := req.Header.Get("X-Amz-Content-Sha256")
var credential *Credential
var seedSignature, region, service string
var seedDate time.Time
var errCode s3err.ErrorCode
switch contentSha256Header {
// Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD' or 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
case streamingContentSHA256, streamingContentSHA256Trailer:
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", contentSha256Header == streamingContentSHA256Trailer)
credential, seedSignature, region, service, seedDate, errCode = iam.calculateSeedSignature(req)
if errCode != s3err.ErrNone {
return nil, errCode
}
case streamingUnsignedPayload:
glog.V(3).Infof("streaming unsigned payload")
// The seed signature is a SigV4 concept; recompute it only for SigV4-signed
// requests. JWT-bearer, presigned, and anonymous unsigned-streaming uploads
// carry no header seed, and verifyV4Signature would fail parsing them.
if isRequestSignatureV4(req) {
// We do not need to pass the seed signature to the Reader as each chunk is not signed,
// but we do compute it to verify the caller has the correct permissions.
_, _, _, _, _, errCode = iam.calculateSeedSignature(req)
if errCode != s3err.ErrNone {
return nil, errCode
}
}
}
// Get the checksum algorithm from the x-amz-trailer Header.
amzTrailerHeader := req.Header.Get("x-amz-trailer")
checksumAlgorithm, err := extractChecksumAlgorithm(amzTrailerHeader)
if err != nil {
glog.V(3).Infof("error extracting checksum algorithm: %v", err)
return nil, s3err.ErrInvalidRequest
}
checkSumWriter := getCheckSumWriter(checksumAlgorithm)
hasTrailer := amzTrailerHeader != ""
return &s3ChunkedReader{
cred: credential,
reader: bufio.NewReader(req.Body),
seedSignature: seedSignature,
seedDate: seedDate,
region: region,
service: service,
chunkSHA256Writer: sha256.New(),
checkSumAlgorithm: checksumAlgorithm.String(),
checkSumWriter: checkSumWriter,
state: readChunkHeader,
iam: iam,
hasTrailer: hasTrailer,
}, s3err.ErrNone
}
func extractChecksumAlgorithm(amzTrailerHeader string) (ChecksumAlgorithm, error) {
// Extract checksum algorithm from the x-amz-trailer header.
switch amzTrailerHeader {
case "x-amz-checksum-crc32":
return ChecksumAlgorithmCRC32, nil
case "x-amz-checksum-crc32c":
return ChecksumAlgorithmCRC32C, nil
case "x-amz-checksum-crc64nvme":
return ChecksumAlgorithmCRC64NVMe, nil
case "x-amz-checksum-sha1":
return ChecksumAlgorithmSHA1, nil
case "x-amz-checksum-sha256":
return ChecksumAlgorithmSHA256, nil
case "":
return ChecksumAlgorithmNone, nil
default:
return ChecksumAlgorithmNone, errors.New("unsupported checksum algorithm '" + amzTrailerHeader + "'")
}
}
// Represents the overall state that is required for decoding a
// AWS Signature V4 chunked reader.
type s3ChunkedReader struct {
cred *Credential
reader *bufio.Reader
seedSignature string
seedDate time.Time
region string
service string // Service from credential scope (e.g., "s3", "iam")
state chunkState
lastChunk bool
chunkSignature string // Empty string if unsigned streaming upload.
checkSumAlgorithm string // Empty string if no checksum algorithm is specified.
checkSumWriter hash.Hash
chunkSHA256Writer hash.Hash // Calculates sha256 of chunk data.
n uint64 // Unread bytes in chunk
err error
iam *IdentityAccessManagement
hasTrailer bool
}
// Read chunk reads the chunk token signature portion.
func (cr *s3ChunkedReader) readS3ChunkHeader() {
// Read the first chunk line until CRLF.
var bytesRead, hexChunkSize, hexChunkSignature []byte
bytesRead, cr.err = readChunkLine(cr.reader)
// Parse s3 specific chunk extension and fetch the values.
hexChunkSize, hexChunkSignature = parseS3ChunkExtension(bytesRead)
if cr.err != nil {
return
}
// <hex>;token=value - converts the hex into its uint64 form.
cr.n, cr.err = parseHexUint(hexChunkSize)
if cr.err != nil {
return
}
if cr.n == 0 {
cr.err = io.EOF
}
// Save the incoming chunk signature.
if hexChunkSignature == nil {
// We are using unsigned streaming upload.
cr.chunkSignature = ""
} else {
cr.chunkSignature = string(hexChunkSignature)
}
}
type chunkState int
const (
readChunkHeader chunkState = iota
readChunkTrailer
readChunk
readTrailerChunk
verifyChunk
verifyChecksum
eofChunk
)
func (cs chunkState) String() string {
stateString := ""
switch cs {
case readChunkHeader:
stateString = "readChunkHeader"
case readChunkTrailer:
stateString = "readChunkTrailer"
case readChunk:
stateString = "readChunk"
case readTrailerChunk:
stateString = "readTrailerChunk"
case verifyChunk:
stateString = "verifyChunk"
case verifyChecksum:
stateString = "verifyChecksum"
case eofChunk:
stateString = "eofChunk"
}
return stateString
}
func (cr *s3ChunkedReader) Close() (err error) {
return nil
}
// Read - implements `io.Reader`, which transparently decodes
// the incoming AWS Signature V4 streaming signature.
func (cr *s3ChunkedReader) Read(buf []byte) (n int, err error) {
for {
switch cr.state {
case readChunkHeader:
cr.readS3ChunkHeader()
// If we're at the end of a chunk.
if cr.n == 0 && cr.err == io.EOF {
cr.state = readChunkTrailer
cr.lastChunk = true
continue
}
if cr.err != nil {
return 0, cr.err
}
cr.state = readChunk
case readChunkTrailer:
err = peekCRLF(cr.reader)
isTrailingChunk := cr.n == 0 && cr.lastChunk
if !isTrailingChunk {
// If we're not in the trailing chunk, we should consume the bytes no matter what.
// The error returned by peekCRLF is the same as the one by readCRLF.
readCRLF(cr.reader)
cr.err = err
} else if err != nil && err != errMalformedEncoding {
cr.err = err
return 0, errMalformedEncoding
} else { // equivalent to isTrailingChunk && err == errMalformedEncoding
// FIXME: The "right" structure of the last chunk as provided by the examples in the
// AWS documentation is "0\r\n\r\n" instead of "0\r\n", but some s3 clients when calling with
// streaming-unsigned-payload-trailer omit the last CRLF. To avoid returning an error that, we need to accept both.
// We arrive here when we're at the end of the 0-byte chunk, depending on the client implementation
// the client may or may not send the optional CRLF after the 0-byte chunk.
// If the client sends the optional CRLF, we should consume it.
if err == nil {
readCRLF(cr.reader)
}
}
// If we're using unsigned streaming upload, there is no signature to verify at each chunk.
if cr.lastChunk && cr.hasTrailer {
cr.state = readTrailerChunk
} else if cr.chunkSignature != "" {
cr.state = verifyChunk
} else {
cr.state = readChunkHeader
}
case readTrailerChunk:
// When using unsigned upload, this would be the raw contents of the trailer chunk:
//
// x-amz-checksum-crc32:YABb/g==\n\r\n\r\n // Trailer chunk (note optional \n character)
// \r\n // CRLF
//
// When using signed upload with an additional checksum algorithm, this would be the raw contents of the trailer chunk:
//
// x-amz-checksum-crc32:YABb/g==\n\r\n // Trailer chunk (note optional \n character)
// trailer-signature\r\n
// \r\n // CRLF
//
// This implementation currently only supports the first case.
// TODO: Implement the second case (signed upload with additional checksum computation for each chunk)
extractedCheckSumAlgorithm, extractedChecksum, err := parseChunkChecksum(cr.reader)
if err != nil {
cr.err = err
return 0, err
}
if extractedCheckSumAlgorithm.String() != cr.checkSumAlgorithm {
errorMessage := fmt.Sprintf("checksum algorithm in trailer '%s' does not match the one advertised in the header '%s'", extractedCheckSumAlgorithm.String(), cr.checkSumAlgorithm)
glog.V(3).Info(errorMessage)
cr.err = errors.New(s3err.ErrMsgChecksumAlgorithmMismatch)
return 0, cr.err
}
// Validate checksum for data integrity (required for both signed and unsigned streaming with trailers)
computedChecksum := cr.checkSumWriter.Sum(nil)
base64Checksum := base64.StdEncoding.EncodeToString(computedChecksum)
if string(extractedChecksum) != base64Checksum {
glog.V(3).Infof("payload checksum '%s' does not match provided checksum '%s'", base64Checksum, string(extractedChecksum))
cr.err = errors.New(s3err.ErrMsgPayloadChecksumMismatch)
return 0, cr.err
}
// TODO: Extract signature from trailer chunk and verify it.
// For now, we just read the trailer chunk and discard it.
cr.state = eofChunk
case readChunk:
// There is no more space left in the request buffer.
if len(buf) == 0 {
return n, nil
}
rbuf := buf
// The request buffer is larger than the current chunk size.
// Read only the current chunk from the underlying reader.
if uint64(len(rbuf)) > cr.n {
rbuf = rbuf[:cr.n]
}
var n0 int
n0, cr.err = cr.reader.Read(rbuf)
if cr.err != nil {
// We have lesser than chunk size advertised in chunkHeader, this is 'unexpected'.
if cr.err == io.EOF {
cr.err = io.ErrUnexpectedEOF
}
return 0, cr.err
}
// Calculate sha256.
cr.chunkSHA256Writer.Write(rbuf[:n0])
// Compute checksum
if cr.checkSumWriter != nil {
cr.checkSumWriter.Write(rbuf[:n0])
}
// Update the bytes read into request buffer so far.
n += n0
buf = buf[n0:]
// Update bytes to be read of the current chunk before verifying chunk's signature.
cr.n -= uint64(n0)
// If we're at the end of a chunk.
if cr.n == 0 {
cr.state = readChunkTrailer
}
case verifyChunk:
// Check if we have credentials for signature verification
// This handles the case where we have unsigned streaming (no cred) but chunks contain signatures
//
// BUG FIX for GitHub issue #6847:
// Some AWS SDK versions (Java 3.7.412+, .NET 4.0.0-preview.6+) send mixed format:
// - HTTP headers indicate unsigned streaming (STREAMING-UNSIGNED-PAYLOAD-TRAILER)
// - But chunk data contains chunk-signature headers (normally only for signed streaming)
// This causes a nil pointer dereference when trying to verify signatures without credentials
if cr.cred != nil {
// Normal signed streaming - verify the chunk signature
// Calculate the hashed chunk.
hashedChunk := hex.EncodeToString(cr.chunkSHA256Writer.Sum(nil))
// Calculate the chunk signature.
newSignature := cr.getChunkSignature(hashedChunk)
if !compareSignatureV4(cr.chunkSignature, newSignature) {
// Chunk signature doesn't match we return signature does not match.
cr.err = errors.New(s3err.ErrMsgChunkSignatureMismatch)
return 0, cr.err
}
// Newly calculated signature becomes the seed for the next chunk
// this follows the chaining.
cr.seedSignature = newSignature
} else {
// For unsigned streaming, we should not verify chunk signatures even if they are present
// This fixes the bug where AWS SDKs send chunk signatures with unsigned streaming headers
glog.V(3).Infof("Skipping chunk signature verification for unsigned streaming")
}
// Common cleanup and state transition for both signed and unsigned streaming
cr.chunkSHA256Writer.Reset()
if cr.lastChunk {
cr.state = eofChunk
} else {
cr.state = readChunkHeader
}
case eofChunk:
return n, io.EOF
}
}
}
// getChunkSignature - get chunk signature.
func (cr *s3ChunkedReader) getChunkSignature(hashedChunk string) string {
// Calculate string to sign.
stringToSign := signV4Algorithm + "-PAYLOAD" + "\n" +
cr.seedDate.Format(iso8601Format) + "\n" +
getScope(cr.seedDate, cr.region, cr.service) + "\n" +
cr.seedSignature + "\n" +
emptySHA256 + "\n" +
hashedChunk
// Get hmac signing key.
signingKey := getSigningKey(cr.cred.SecretKey, cr.seedDate.Format(yyyymmdd), cr.region, cr.service)
// Calculate and return signature.
return getSignature(signingKey, stringToSign)
}
func readCRLF(reader *bufio.Reader) error {
buf := make([]byte, 2)
_, err := io.ReadFull(reader, buf)
if err != nil {
return err
}
return checkCRLF(buf)
}
func peekCRLF(reader *bufio.Reader) error {
buf, err := reader.Peek(2)
if err != nil {
return err
}
if err := checkCRLF(buf); err != nil {
return err
}
return nil
}
func checkCRLF(buf []byte) error {
if len(buf) != 2 || buf[0] != '\r' || buf[1] != '\n' {
return errMalformedEncoding
}
return nil
}
func readChunkLine(b *bufio.Reader) ([]byte, error) {
buf, err := b.ReadSlice('\n')
if err != nil {
// We always know when EOF is coming.
// If the caller asked for a line, there should be a line.
switch err {
case io.EOF:
err = io.ErrUnexpectedEOF
case bufio.ErrBufferFull:
err = errLineTooLong
}
return nil, err
}
if len(buf) >= maxLineLength {
return nil, errLineTooLong
}
return trimTrailingWhitespace(buf), nil
}
// trimTrailingWhitespace - trim trailing white space.
func trimTrailingWhitespace(b []byte) []byte {
for len(b) > 0 && isASCIISpace(b[len(b)-1]) {
b = b[:len(b)-1]
}
return b
}
// isASCIISpace - is ascii space?
func isASCIISpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
// Constant s3 chunk encoding signature.
const s3ChunkSignatureStr = ";chunk-signature="
// parseS3ChunkExtension removes any s3 specific chunk-extension from buf.
// For example,
//
// "10000;chunk-signature=..." => "10000", "chunk-signature=..."
func parseS3ChunkExtension(buf []byte) ([]byte, []byte) {
buf = trimTrailingWhitespace(buf)
semi := bytes.Index(buf, []byte(s3ChunkSignatureStr))
// Chunk signature not found, return the whole buffer.
// This means we're using unsigned streaming upload.
if semi == -1 {
return buf, nil
}
return buf[:semi], parseChunkSignature(buf[semi:])
}
func parseChunkChecksum(b *bufio.Reader) (ChecksumAlgorithm, []byte, error) {
// Read trailer lines until empty line
var checksumAlgorithm ChecksumAlgorithm
var checksum []byte
for {
bytesRead, err := readChunkLine(b)
if err != nil {
return ChecksumAlgorithmNone, nil, err
}
if len(bytesRead) == 0 {
break
}
parts := bytes.SplitN(bytesRead, []byte(":"), 2)
if len(parts) == 2 {
key := string(bytes.TrimSpace(parts[0]))
value := bytes.TrimSpace(parts[1])
if alg, err := extractChecksumAlgorithm(key); err == nil {
if checksumAlgorithm != ChecksumAlgorithmNone {
glog.V(3).Infof("multiple checksum headers found in trailer, using last: %s", key)
}
checksumAlgorithm = alg
checksum = value
}
// Ignore other trailer headers like x-amz-trailer-signature
}
}
return checksumAlgorithm, checksum, nil
}
func parseChunkSignature(chunk []byte) []byte {
chunkSplits := bytes.SplitN(chunk, []byte("="), 2)
return chunkSplits[1] // Keep only the signature.
}
func parseHexUint(v []byte) (n uint64, err error) {
for i, b := range v {
switch {
case '0' <= b && b <= '9':
b = b - '0'
case 'a' <= b && b <= 'f':
b = b - 'a' + 10
case 'A' <= b && b <= 'F':
b = b - 'A' + 10
default:
return 0, errors.New("invalid byte in chunk length")
}
if i == 16 {
return 0, errors.New("http chunk length too large")
}
n <<= 4
n |= uint64(b)
}
return
}
// Checksum Algorithm represents the various checksum algorithms supported.
type ChecksumAlgorithm int
const (
ChecksumAlgorithmNone ChecksumAlgorithm = iota
ChecksumAlgorithmCRC32
ChecksumAlgorithmCRC32C
ChecksumAlgorithmCRC64NVMe
ChecksumAlgorithmSHA1
ChecksumAlgorithmSHA256
)
func (ca ChecksumAlgorithm) String() string {
switch ca {
case ChecksumAlgorithmNone:
return ""
case ChecksumAlgorithmCRC32:
return "x-amz-checksum-crc32"
case ChecksumAlgorithmCRC32C:
return "x-amz-checksum-crc32c"
case ChecksumAlgorithmCRC64NVMe:
return "x-amz-checksum-crc64nvme"
case ChecksumAlgorithmSHA1:
return "x-amz-checksum-sha1"
case ChecksumAlgorithmSHA256:
return "x-amz-checksum-sha256"
}
return ""
}
// getCheckSumWriter - get checksum writer.
func getCheckSumWriter(checksumAlgorithm ChecksumAlgorithm) hash.Hash {
switch checksumAlgorithm {
case ChecksumAlgorithmCRC32:
return crc32.NewIEEE()
case ChecksumAlgorithmCRC32C:
return crc32.New(crc32.MakeTable(crc32.Castagnoli))
case ChecksumAlgorithmCRC64NVMe:
return crc64nvme.New()
case ChecksumAlgorithmSHA1:
return sha1.New()
case ChecksumAlgorithmSHA256:
return sha256.New()
}
return nil
}