Skip to content

Commit 75848b2

Browse files
milanmajchrakclaude
andcommitted
Fix the S3 defects a 10-reviewer audit found in the SDK v2 port
**Silent data loss on multipart upload.** `uploadPart` was handed a `BoundedInputStream` over a `FileInputStream`, which cannot be reset, so the SDK refused to retry a part after any transient error ("Request cannot be retried, because the request stream could not be reset"). The v1 SDK passed the file plus an offset and retried freely. The failure then landed in a catch that logged and swallowed, `completeMultipartUpload` never ran, and `put()` carried on setting size and checksum - DSpace recorded a bitstream that S3 does not hold, with no error anywhere. Now uses the public `AsyncRequestBody.fromFile(FileRequestBodyConfiguration)` with `position`/`numBytesToRead`, which is file-backed and therefore retryable; this also removes the hand-rolled `openPart` helper and its executor. A failed upload now aborts the multipart upload (parts were previously leaked and billed) and throws instead of returning normally. **The multipart loop was never executed more than once.** 50 MB part size against a 23-byte fixture meant one iteration with offset 0, so the offset arithmetic and last-part handling this port rewrote had no coverage at all. `uploadPartSizeBytes` is now settable and `syncStoreUploadsInMultipleParts` uploads 5 MB + 4 KB across a real part boundary, verifying the object byte for byte. **A tripwire in `amazonClientBuilderBy` left all 21 S3 tests green.** Every test injects a ready-made client, so `FunctionalUtils.getDefaultOrBuild` never called the supplier and `endpointOverride`, `forcePathStyle`, `maxConcurrency` and the part sizes never ran - the fork's headline delta, configurable `pathStyleAccessEnabled`, was verified only at its getter. `clientBuilderAppliesEndpointAndPathStyle` builds a client through that method and uses it against LocalStack. Verified it detects breakage: removing `endpointOverride` turns it red, restoring it turns it green. **Content-Disposition had two implementations and the newer one was wrong.** It escaped `"` but not `\`, so a bitstream named `evil\` terminated the quoted string and swallowed `filename*`; and it used `URLEncoder` directly, so a space arrived as `+`, which RFC 8187 reads literally - re-introducing the bug vanilla had just fixed. Extracted the correct implementation to `org.dspace.util.ContentDispositionUtils`, now used by both call sites. **Enabling direct downloads silently disabled inline preview.** The redirect hardcoded `attachment`, discarding the disposition the allowlist and the 8 MB threshold had just computed. `generatePresignedUrl` takes the caller's disposition; `BitstreamRestController` passes the exact header the non-redirect path would have sent. **Presigner region could diverge from the client's.** It hardcoded `us-east-1` while the client leaves the region to the default provider chain when no credentials are configured, so presigned URLs would be signed for the wrong region on an IAM role outside us-east-1. It now mirrors `init()` branch for branch. **A 403 was reported as "bucket absent".** `doesBucketExist` treated everything except `NoSuchBucketException` as absent, so `init()` would try to create a bucket that already exists; a least-privilege policy denies that and the assetstore comes up dead. Only 404 and `NoSuchBucketException` now answer false. `doesObjectExist` likewise stops reporting a 403 or a timeout as "object does not exist" with the cause hidden at DEBUG. **`s3ChecksumAlgorithm` was dead configuration.** `bitstore.xml` wires `SyncS3BitStoreService`, which overrides `put()`, so the parent's `putObject` - the only consumer - never ran. `uploadFluently` now applies it. Also `${assetstore.s3.endpoint:}` is self-defaulting, matching vanilla. Tests: S3BitStoreServiceIT 15, ClarinS3BitStoreServiceIT 8, S3DirectDownloadServiceTest 11, ContentDispositionUtilsTest 9 - all 0 failures, checkstyle 0 violations. Maven does not recompile edited sources reliably here, so every run used `-Dmaven.compiler.useIncrementalCompilation=false`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1bfc45b commit 75848b2

11 files changed

Lines changed: 395 additions & 71 deletions

File tree

dspace-api/src/main/java/org/dspace/storage/bitstore/S3BitStoreService.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm;
5959
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
6060
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
61+
import software.amazon.awssdk.services.s3.model.S3Exception;
6162

6263
/**
6364
* Asset store using Amazon's Simple Storage Service (S3).
@@ -262,11 +263,18 @@ public boolean doesBucketExist(String bucketName ) {
262263
s3AsyncClient.headBucket(r -> r.bucket(bucketName)).join();
263264
return true;
264265
} catch (CompletionException ce) {
265-
if (!(ce.getCause() instanceof NoSuchBucketException)) {
266-
log.error("headBucket(" + bucketName + ")", ce.getCause());
266+
Throwable cause = ce.getCause();
267+
if (cause instanceof NoSuchBucketException
268+
|| (cause instanceof S3Exception
269+
&& ((S3Exception) cause).statusCode() == HttpStatusCode.NOT_FOUND)) {
270+
return false;
267271
}
268272

269-
return false;
273+
// CLARIN: only a genuinely absent bucket may answer "false". Reporting a 403 as "absent" makes
274+
// init() try to create a bucket that already exists, which a least-privilege policy denies -
275+
// and the assetstore then comes up dead. The v1 SDK's doesBucketExistV2 drew the same line.
276+
log.error("headBucket(" + bucketName + ") failed for a reason other than an absent bucket", cause);
277+
throw ce;
270278
}
271279
}
272280

dspace-api/src/main/java/org/dspace/storage/bitstore/S3DirectDownloadServiceImpl.java

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,25 @@
99

1010
import java.io.IOException;
1111
import java.net.URI;
12-
import java.net.URLEncoder;
13-
import java.nio.charset.StandardCharsets;
1412
import java.time.Duration;
13+
import java.util.concurrent.CompletionException;
1514

1615
import org.apache.commons.lang3.StringUtils;
1716
import org.dspace.services.ConfigurationService;
1817
import org.dspace.storage.bitstore.service.S3DirectDownloadService;
18+
import org.dspace.util.ContentDispositionUtils;
1919
import org.slf4j.Logger;
2020
import org.slf4j.LoggerFactory;
2121
import org.springframework.beans.factory.annotation.Autowired;
2222
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
2323
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
24+
import software.amazon.awssdk.http.HttpStatusCode;
2425
import software.amazon.awssdk.regions.Region;
2526
import software.amazon.awssdk.services.s3.S3AsyncClient;
2627
import software.amazon.awssdk.services.s3.S3Configuration;
2728
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
29+
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
30+
import software.amazon.awssdk.services.s3.model.S3Exception;
2831
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
2932
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
3033

@@ -90,17 +93,28 @@ private S3Presigner buildPresigner() {
9093
}
9194
// otherwise fall back to the default provider chain (IAM role / environment), as S3BitStoreService does
9295

93-
// Mirror S3BitStoreService: an unset or unparseable region falls back to us-east-1
94-
Region region = Region.US_EAST_1;
96+
// Mirror S3BitStoreService exactly. With explicit credentials it falls back to us-east-1; without
97+
// them it leaves the region unset so the default provider chain resolves it. Hardcoding us-east-1
98+
// in the no-credentials case would sign against the wrong region on an IAM role outside us-east-1,
99+
// and the signature would be rejected.
95100
String regionName = s3BitStoreService.getAwsRegionName();
96-
if (StringUtils.isNotBlank(regionName)) {
101+
if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) {
102+
Region region = Region.US_EAST_1;
103+
if (StringUtils.isNotBlank(regionName)) {
104+
try {
105+
region = Region.of(regionName);
106+
} catch (IllegalArgumentException e) {
107+
log.warn("Invalid aws_region: {}", regionName);
108+
}
109+
}
110+
builder.region(region);
111+
} else if (StringUtils.isNotBlank(regionName)) {
97112
try {
98-
region = Region.of(regionName);
113+
builder.region(Region.of(regionName));
99114
} catch (IllegalArgumentException e) {
100115
log.warn("Invalid aws_region: {}", regionName);
101116
}
102117
}
103-
builder.region(region);
104118

105119
String endpoint = s3BitStoreService.getEndpoint();
106120
if (StringUtils.isNotBlank(endpoint)) {
@@ -115,18 +129,40 @@ private S3Presigner buildPresigner() {
115129

116130
/**
117131
* Whether the object is really there. A URL we cannot verify is never signed.
132+
*
133+
* The v1 SDK's `doesObjectExist` rethrew anything that was not a 404. There is no v2 equivalent, so
134+
* this stays fail-closed - but a 403, an expired credential or a timeout is a misconfiguration the
135+
* operator has to see, not a missing object, so it is logged at ERROR rather than DEBUG.
118136
*/
119137
private boolean doesObjectExist(String bucket, String key) {
120138
try {
121139
s3Client.headObject(r -> r.bucket(bucket).key(key)).join();
122140
return true;
141+
} catch (CompletionException e) {
142+
Throwable cause = e.getCause();
143+
if (cause instanceof NoSuchKeyException
144+
|| (cause instanceof S3Exception
145+
&& ((S3Exception) cause).statusCode() == HttpStatusCode.NOT_FOUND)) {
146+
log.debug("headObject(bucket={}, key={}): object not found", bucket, key);
147+
} else {
148+
log.error("headObject(bucket={}, key={}) failed for a reason other than a missing object; "
149+
+ "refusing to sign a URL", bucket, key, cause);
150+
}
151+
return false;
123152
} catch (Exception e) {
124-
log.debug("headObject(bucket={}, key={}) failed", bucket, key, e);
153+
log.error("headObject(bucket={}, key={}) failed; refusing to sign a URL", bucket, key, e);
125154
return false;
126155
}
127156
}
128157

158+
@Override
129159
public String generatePresignedUrl(String bucket, String key, int expirationSeconds, String desiredFilename) {
160+
return generatePresignedUrl(bucket, key, expirationSeconds, desiredFilename, null);
161+
}
162+
163+
@Override
164+
public String generatePresignedUrl(String bucket, String key, int expirationSeconds, String desiredFilename,
165+
String contentDispositionOverride) {
130166
if (desiredFilename == null) {
131167
log.error("Cannot generate presigned URL – desired filename is null");
132168
throw new IllegalArgumentException("Desired filename cannot be null");
@@ -140,14 +176,12 @@ public String generatePresignedUrl(String bucket, String key, int expirationSeco
140176
throw new IllegalArgumentException("Requested S3 object does not exist");
141177
}
142178

143-
// Add custom response header for filename - to download the file with the desired name
144-
// Remove CRLF and quotes to prevent header injection
145-
String safeName = desiredFilename.replaceAll("[\\r\\n\"]", "_");
146-
// RFC-5987: percent-encode UTF-8, e.g. filename*=UTF-8''%E2%82%ACrates.txt
147-
String encoded = URLEncoder.encode(desiredFilename, StandardCharsets.UTF_8);
148-
String contentDisposition = String.format(
149-
"attachment; filename=\"%s\"; filename*=UTF-8''%s",
150-
safeName, encoded);
179+
// Add custom response header for filename - to download the file with the desired name.
180+
// The caller passes the disposition it would have served itself, so that redirecting to S3 does not
181+
// silently turn an inline preview into a download; falling back to `attachment` is the safe default.
182+
String contentDisposition = StringUtils.isNotBlank(contentDispositionOverride)
183+
? contentDispositionOverride
184+
: ContentDispositionUtils.build(ContentDispositionUtils.ATTACHMENT, desiredFilename);
151185

152186
try {
153187
GetObjectRequest getObjectRequest = GetObjectRequest.builder()

dspace-api/src/main/java/org/dspace/storage/bitstore/SyncS3BitStoreService.java

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,19 @@
1919
import java.util.ArrayList;
2020
import java.util.List;
2121
import java.util.concurrent.CompletionException;
22-
import java.util.concurrent.ExecutorService;
23-
import java.util.concurrent.Executors;
2422

2523
import org.apache.commons.io.IOUtils;
26-
import org.apache.commons.io.input.BoundedInputStream;
2724
import org.apache.commons.lang3.StringUtils;
2825
import org.apache.logging.log4j.LogManager;
2926
import org.apache.logging.log4j.Logger;
3027
import org.dspace.content.Bitstream;
3128
import org.dspace.core.Utils;
3229
import org.dspace.services.ConfigurationService;
3330
import org.springframework.beans.factory.annotation.Autowired;
31+
import software.amazon.awssdk.core.FileRequestBodyConfiguration;
3432
import software.amazon.awssdk.core.async.AsyncRequestBody;
3533
import software.amazon.awssdk.core.exception.SdkException;
34+
import software.amazon.awssdk.services.s3.model.ChecksumAlgorithm;
3635
import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
3736
import software.amazon.awssdk.services.s3.model.CompletedPart;
3837
import software.amazon.awssdk.services.s3.model.UploadPartResponse;
@@ -52,8 +51,12 @@ public class SyncS3BitStoreService extends S3BitStoreService {
5251

5352
/**
5453
* The uploading file is divided into parts and each part is uploaded separately. The size of the part is 50 MB.
54+
*
55+
* Settable so the multipart path can actually be exercised by a test: with the 50 MB default and a small
56+
* fixture the loop only ever runs once, which leaves the offset arithmetic and the last-part handling
57+
* unverified. S3 requires every part except the last to be at least 5 MB.
5558
*/
56-
private static final long UPLOAD_FILE_PART_SIZE = 50 * 1024 * 1024; // 50 MB
59+
private long uploadPartSizeBytes = 50 * 1024 * 1024; // 50 MB
5760

5861
/**
5962
* Upload large file by parts - check the checksum of every part
@@ -189,8 +192,15 @@ private void createFileIfNotExist(File localFile) throws IOException {
189192
* @param scratchFile the file to upload
190193
*/
191194
private void uploadFluently(String key, File scratchFile) {
192-
s3AsyncClient.putObject(r -> r.bucket(getBucketName()).key(key),
193-
AsyncRequestBody.fromFile(scratchFile)).join();
195+
// `assetstore.s3.s3ChecksumAlgorithm` would otherwise be dead configuration for the fork:
196+
// bitstore.xml wires this class, which overrides put(), so the parent's putObject never runs.
197+
ChecksumAlgorithm algorithm = getS3ChecksumAlgorithm();
198+
s3AsyncClient.putObject(r -> {
199+
r.bucket(getBucketName()).key(key);
200+
if (algorithm != null) {
201+
r.checksumAlgorithm(algorithm);
202+
}
203+
}, AsyncRequestBody.fromFile(scratchFile)).join();
194204
}
195205

196206
/**
@@ -217,28 +227,31 @@ private void uploadByParts(String key, File scratchFile) throws IOException {
217227
// Create a list to hold the ETags for individual parts
218228
List<CompletedPart> completedParts = new ArrayList<>();
219229

220-
ExecutorService executor = Executors.newSingleThreadExecutor();
221230
try {
222231
// Upload parts
223232
long fileLength = scratchFile.length();
224233
long remainingBytes = fileLength;
225234
int partNumber = 1;
226235

227236
while (remainingBytes > 0) {
228-
long bytesToUpload = Math.min(UPLOAD_FILE_PART_SIZE, remainingBytes);
237+
long bytesToUpload = Math.min(uploadPartSizeBytes, remainingBytes);
229238
long offset = fileLength - remainingBytes;
230239

231240
// Calculate the checksum for the part
232241
String partChecksum = calculatePartChecksum(scratchFile, offset, bytesToUpload, digest);
233242

234243
final int currentPartNumber = partNumber;
235-
UploadPartResponse uploadPartResponse;
236-
try (InputStream partStream = openPart(scratchFile, offset, bytesToUpload)) {
237-
// Upload the part
238-
uploadPartResponse = s3AsyncClient.uploadPart(
239-
r -> r.bucket(getBucketName()).key(key).uploadId(uploadId).partNumber(currentPartNumber),
240-
AsyncRequestBody.fromInputStream(partStream, bytesToUpload, executor)).join();
241-
}
244+
// A file-backed body, not a stream: the SDK re-reads it from the start when it retries a
245+
// part. A non-resettable stream makes any transient S3 error permanent
246+
// ("Request cannot be retried, because the request stream could not be reset"), which is
247+
// what the v1 SDK avoided by taking the file plus an offset.
248+
UploadPartResponse uploadPartResponse = s3AsyncClient.uploadPart(
249+
r -> r.bucket(getBucketName()).key(key).uploadId(uploadId).partNumber(currentPartNumber),
250+
AsyncRequestBody.fromFile(FileRequestBodyConfiguration.builder()
251+
.path(scratchFile.toPath())
252+
.position(offset)
253+
.numBytesToRead(bytesToUpload)
254+
.build())).join();
242255

243256
// Collect the ETag for the part
244257
completedParts.add(CompletedPart.builder()
@@ -263,33 +276,42 @@ private void uploadByParts(String key, File scratchFile) throws IOException {
263276
// Complete the multipart upload
264277
s3AsyncClient.completeMultipartUpload(r -> r.bucket(getBucketName()).key(key).uploadId(uploadId)
265278
.multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build())).join();
279+
} catch (IOException e) {
280+
abortQuietly(key, uploadId);
281+
throw e;
266282
} catch (SdkException | CompletionException e) {
267-
log.error("Cannot upload the file by parts because: ", e);
268-
} finally {
269-
executor.shutdown();
283+
// This used to be logged and swallowed, which let put() carry on and record a bitstream that
284+
// S3 does not actually hold - silent data loss. Abort so the parts are not billed forever.
285+
abortQuietly(key, uploadId);
286+
throw new IOException("Multipart upload of " + key + " failed", e);
270287
}
271288
}
272289

273290
/**
274-
* Open a stream over exactly one part of the file, without loading it into memory.
291+
* Abort a multipart upload, reporting but not rethrowing - the caller is already failing and the
292+
* original cause is the one worth propagating.
275293
*
276-
* @param file the uploading file
277-
* @param offset the offset in the file
278-
* @param length the length of the part
279-
* @return a stream bounded to the requested part
280-
* @throws IOException if an I/O error occurs
294+
* @param key the bitstream's internalId
295+
* @param uploadId the multipart upload to abort
281296
*/
282-
private static InputStream openPart(File file, long offset, long length) throws IOException {
283-
FileInputStream fis = new FileInputStream(file);
297+
private void abortQuietly(String key, String uploadId) {
284298
try {
285-
fis.getChannel().position(offset);
286-
return BoundedInputStream.builder().setInputStream(fis).setMaxCount(length).get();
287-
} catch (IOException | RuntimeException e) {
288-
fis.close();
289-
throw e;
299+
s3AsyncClient.abortMultipartUpload(
300+
r -> r.bucket(getBucketName()).key(key).uploadId(uploadId)).join();
301+
} catch (SdkException | CompletionException e) {
302+
log.error("Could not abort multipart upload " + uploadId + " for " + key
303+
+ "; its parts will remain until a lifecycle rule removes them", e);
290304
}
291305
}
292306

307+
public long getUploadPartSizeBytes() {
308+
return uploadPartSizeBytes;
309+
}
310+
311+
public void setUploadPartSizeBytes(long uploadPartSizeBytes) {
312+
this.uploadPartSizeBytes = uploadPartSizeBytes;
313+
}
314+
293315
/**
294316
* Calculate the checksum of the specified part of the file (Multipart upload)
295317
*

dspace-api/src/main/java/org/dspace/storage/bitstore/service/S3DirectDownloadService.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,20 @@ public interface S3DirectDownloadService {
2626
*/
2727
String generatePresignedUrl(String bucket, String key, int expirationSeconds, String bitstreamName)
2828
throws UnsupportedEncodingException;
29+
30+
/**
31+
* Generate a presigned URL, serving it with a caller-supplied Content-Disposition.
32+
*
33+
* Without this the redirect always forced `attachment`, so turning on direct downloads silently
34+
* disabled inline preview for every format in `webui.content_disposition_inline`.
35+
*
36+
* @param bucket The S3 bucket name
37+
* @param key The bitstream path in the S3 bucket
38+
* @param expirationSeconds The number of seconds until the URL expires
39+
* @param bitstreamName The name of the bitstream, used when no override is given
40+
* @param contentDispositionOverride The exact Content-Disposition to serve, or null for `attachment`
41+
* @return A string containing the presigned URL for direct download access
42+
*/
43+
String generatePresignedUrl(String bucket, String key, int expirationSeconds, String bitstreamName,
44+
String contentDispositionOverride) throws UnsupportedEncodingException;
2945
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* The contents of this file are subject to the license and copyright
3+
* detailed in the LICENSE and NOTICE files at the root of the source
4+
* tree and available online at
5+
*
6+
* http://www.dspace.org/license/
7+
*/
8+
package org.dspace.util;
9+
10+
import java.net.URLEncoder;
11+
import java.nio.charset.StandardCharsets;
12+
13+
/**
14+
* Builds a `Content-Disposition` header value from a bitstream name.
15+
*
16+
* The fork had two independent implementations of this, one of them wrong: it escaped `"` but not `\`,
17+
* so a bitstream named `evil\` terminated the quoted string early and swallowed the `filename*`
18+
* parameter; and it used {@link URLEncoder} directly, which encodes a space as `+`. RFC 8187 treats
19+
* `+` as a literal character, so `my report.pdf` arrived as `my+report.pdf`.
20+
*
21+
* @author Milan Majchrak (dspace at dataquest.sk)
22+
*/
23+
public final class ContentDispositionUtils {
24+
25+
public static final String ATTACHMENT = "attachment";
26+
public static final String INLINE = "inline";
27+
28+
private ContentDispositionUtils() {
29+
}
30+
31+
/**
32+
* Build a `Content-Disposition` value carrying both the RFC 6266 ASCII fallback and the RFC 8187
33+
* percent-encoded UTF-8 name.
34+
*
35+
* @param disposition `attachment` or `inline`
36+
* @param name the bitstream name; must not be null
37+
* @return the header value
38+
*/
39+
public static String build(String disposition, String name) {
40+
if (name == null) {
41+
throw new IllegalArgumentException("Bitstream name cannot be null");
42+
}
43+
44+
// RFC 8187 percent-encoding for filename*. URLEncoder is form-encoding, so `+` has to be
45+
// converted back to `%20` - a literal `+` in a filename is already encoded as `%2B` by then.
46+
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8)
47+
.replace("+", "%20");
48+
49+
// ASCII fallback for clients that ignore filename*. Non-ASCII becomes `_`; backslash and quote
50+
// are escaped, in that order, so the quoted-string cannot be terminated early.
51+
String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_")
52+
.replace("\\", "\\\\")
53+
.replace("\"", "\\\"");
54+
55+
return String.format("%s; filename=\"%s\"; filename*=UTF-8''%s", disposition, asciiFallback, encoded);
56+
}
57+
}

0 commit comments

Comments
 (0)