Skip to content

Commit 47a647c

Browse files
committed
fix: Mark published S3 objects public-read
Nothing has ever made an artifact readable by the world: CannedACL appears nowhere in this repository's history, so every S3 deployment has depended on a bucket policy to grant it and silently served unreachable firmware where there was none. PublishedArtifactVerifier is what surfaced it, refusing a publish whose every artifact answered 403. The caller now states whether an object is world-readable, because it is the only thing that knows: staged bytes stay private, the published copy and a module zip do not. The parameter has no default, since a forgotten argument is a release nobody can download and nothing fails until a hub tries. Sending the ACL is opt-in via Storage:S3:PublicReadAcl. R2 does not implement ACLs and an AWS bucket on the default Object Ownership rejects them, and on both the write fails rather than being ignored, so a bucket granting public read by policy must keep sending none.
1 parent b13de77 commit 47a647c

7 files changed

Lines changed: 44 additions & 12 deletions

File tree

‎RepositoryServer/Config/StorageConfig.cs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,19 @@ public sealed class S3StorageConfig
5050
/// AWS region. Required for AWS S3, optional for most S3-compatible services.
5151
/// </summary>
5252
public string? Region { get; init; }
53+
54+
/// <summary>
55+
/// Whether to mark world-readable objects public-read with a canned ACL as they are written.
56+
/// </summary>
57+
/// <remarks>
58+
/// Off by default, because ACLs are not universally available and sending one where they are
59+
/// disabled fails the write rather than being ignored: Cloudflare R2 does not implement them at
60+
/// all, and an AWS bucket on the default Object Ownership of "bucket owner enforced" answers
61+
/// AccessControlListNotSupported.
62+
///
63+
/// Leave it off where the bucket grants public read by policy, which is the only option on those
64+
/// stores. Turn it on for a bucket that has ACLs enabled and no such policy. Either way the
65+
/// staging prefix is never marked public: only published artifacts and module zips are.
66+
/// </remarks>
67+
public bool PublicReadAcl { get; init; }
5368
}

‎RepositoryServer/Controllers/V1/CiCdController.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ public async Task<IActionResult> PublishVersion(
151151
// Upload to storage
152152
var storagePath = $"modules/{moduleId}/{moduleVersion}/module.zip";
153153
using var uploadStream = new MemoryStream(fileBytes);
154-
await _storage.UploadFileAsync(storagePath, uploadStream);
154+
await _storage.UploadFileAsync(storagePath, uploadStream, publicRead: true);
155155

156156
// Construct public download URL
157157
var cdnBase = _apiConfig.Modules.CdnBaseUrl.TrimEnd('/');

‎RepositoryServer/Controllers/V2/Firmware/ReleasesController.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ await _db.FirmwareStagedArtifacts
389389
var stagingPath = FirmwareArtifactFileNames.BuildStagingPath(releaseId, boardId, artifactType);
390390

391391
await using var uploadStream = new MemoryStream(bytes);
392-
await _storage.UploadFileAsync(stagingPath, uploadStream, ct);
392+
await _storage.UploadFileAsync(stagingPath, uploadStream, publicRead: false, ct);
393393

394394
_db.FirmwareStagedArtifacts.Add(new FirmwareStagedArtifact
395395
{
@@ -509,7 +509,7 @@ public async Task<IActionResult> PublishRelease([FromRoute] Guid releaseId, Canc
509509
var publishedPath = FirmwareArtifactFileNames.BuildStoragePath(
510510
release.Version, staged.BoardId, staged.ArtifactType);
511511

512-
await _storage.CopyFileAsync(stagingPath, publishedPath, ct);
512+
await _storage.CopyFileAsync(stagingPath, publishedPath, publicRead: true, ct);
513513
promoted.Add(publishedPath);
514514
}
515515
}

‎RepositoryServer/Services/BunnyCdnStorageService.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public BunnyCdnStorageService(HttpClient httpClient, BunnyCdnStorageConfig confi
1818
}
1919

2020
/// <inheritdoc />
21-
public async Task UploadFileAsync(string path, Stream content, CancellationToken cancellationToken = default)
21+
public async Task UploadFileAsync(string path, Stream content, bool publicRead, CancellationToken cancellationToken = default)
2222
{
2323
var url = $"{_storageUrl}/{path}";
2424

@@ -30,7 +30,7 @@ public async Task UploadFileAsync(string path, Stream content, CancellationToken
3030
}
3131

3232
/// <inheritdoc />
33-
public async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default)
33+
public async Task CopyFileAsync(string sourcePath, string destinationPath, bool publicRead, CancellationToken cancellationToken = default)
3434
{
3535
// The Bunny storage API has no server-side copy, so the object is streamed back through the
3636
// server. Firmware artifacts are a few MB, and this runs once per artifact at publish.
@@ -39,7 +39,7 @@ public async Task CopyFileAsync(string sourcePath, string destinationPath, Cance
3939
response.EnsureSuccessStatusCode();
4040

4141
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
42-
await UploadFileAsync(destinationPath, stream, cancellationToken);
42+
await UploadFileAsync(destinationPath, stream, publicRead, cancellationToken);
4343
}
4444

4545
/// <inheritdoc />

‎RepositoryServer/Services/IStorageService.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ public interface IStorageService
77
/// </summary>
88
/// <param name="path">Path relative to storage root, e.g. "1.0.0/board-name/app.bin"</param>
99
/// <param name="content">File content stream</param>
10+
/// <param name="publicRead">Whether the object is served to the world at <c>CdnBaseUrl</c>.</param>
1011
/// <param name="cancellationToken">Cancellation token</param>
11-
Task UploadFileAsync(string path, Stream content, CancellationToken cancellationToken = default);
12+
Task UploadFileAsync(string path, Stream content, bool publicRead, CancellationToken cancellationToken = default);
1213

1314
/// <summary>
1415
/// Copies a file within storage, overwriting the destination.
@@ -20,8 +21,9 @@ public interface IStorageService
2021
/// </remarks>
2122
/// <param name="sourcePath">Existing path relative to storage root.</param>
2223
/// <param name="destinationPath">Path to copy to, relative to storage root.</param>
24+
/// <param name="publicRead">Whether the destination is served to the world at <c>CdnBaseUrl</c>.</param>
2325
/// <param name="cancellationToken">Cancellation token</param>
24-
Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default);
26+
Task CopyFileAsync(string sourcePath, string destinationPath, bool publicRead, CancellationToken cancellationToken = default);
2527

2628
/// <summary>
2729
/// Deletes a file from storage.

‎RepositoryServer/Services/LocalStorageService.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public LocalStorageService(LocalStorageConfig config)
1414
}
1515

1616
/// <inheritdoc />
17-
public async Task UploadFileAsync(string path, Stream content, CancellationToken cancellationToken = default)
17+
public async Task UploadFileAsync(string path, Stream content, bool publicRead, CancellationToken cancellationToken = default)
1818
{
1919
var fullPath = ResolvePath(path);
2020
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
@@ -23,7 +23,7 @@ public async Task UploadFileAsync(string path, Stream content, CancellationToken
2323
}
2424

2525
/// <inheritdoc />
26-
public Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default)
26+
public Task CopyFileAsync(string sourcePath, string destinationPath, bool publicRead, CancellationToken cancellationToken = default)
2727
{
2828
var source = ResolvePath(sourcePath);
2929
var destination = ResolvePath(destinationPath);

‎RepositoryServer/Services/S3StorageService.cs‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ public sealed class S3StorageService : IStorageService, IDisposable
1010
private readonly IAmazonS3 _s3Client;
1111
private readonly string _bucketName;
1212
private readonly string? _keyPrefix;
13+
private readonly bool _publicReadAcl;
1314

1415
public S3StorageService(S3StorageConfig config)
1516
{
1617
_bucketName = config.BucketName;
1718
_keyPrefix = config.KeyPrefix?.TrimEnd('/');
19+
_publicReadAcl = config.PublicReadAcl;
1820

1921
var s3Config = new AmazonS3Config();
2022

@@ -32,21 +34,22 @@ public S3StorageService(S3StorageConfig config)
3234
}
3335

3436
/// <inheritdoc />
35-
public async Task UploadFileAsync(string path, Stream content, CancellationToken cancellationToken = default)
37+
public async Task UploadFileAsync(string path, Stream content, bool publicRead, CancellationToken cancellationToken = default)
3638
{
3739
var request = new PutObjectRequest
3840
{
3941
BucketName = _bucketName,
4042
Key = ResolveKey(path),
4143
InputStream = content,
4244
ContentType = "application/octet-stream",
45+
CannedACL = AclFor(publicRead),
4346
};
4447

4548
await _s3Client.PutObjectAsync(request, cancellationToken);
4649
}
4750

4851
/// <inheritdoc />
49-
public async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default)
52+
public async Task CopyFileAsync(string sourcePath, string destinationPath, bool publicRead, CancellationToken cancellationToken = default)
5053
{
5154
// Server-side copy — the object never travels through this process.
5255
var request = new CopyObjectRequest
@@ -55,6 +58,7 @@ public async Task CopyFileAsync(string sourcePath, string destinationPath, Cance
5558
SourceKey = ResolveKey(sourcePath),
5659
DestinationBucket = _bucketName,
5760
DestinationKey = ResolveKey(destinationPath),
61+
CannedACL = AclFor(publicRead),
5862
};
5963

6064
await _s3Client.CopyObjectAsync(request, cancellationToken);
@@ -125,6 +129,17 @@ public async IAsyncEnumerable<string> ListFilesAsync(string prefix = "", [Enumer
125129
} while (response.IsTruncated == true);
126130
}
127131

132+
/// <summary>
133+
/// The canned ACL to send, or null to send none and leave the bucket's own rules to decide.
134+
/// </summary>
135+
/// <remarks>
136+
/// Null is not "private": it means this server states nothing about who may read the object.
137+
/// On a bucket whose Object Ownership is "bucket owner enforced", or on any store that does not
138+
/// implement ACLs, sending one is an error rather than a no-op, so it has to be opt-in.
139+
/// </remarks>
140+
private S3CannedACL? AclFor(bool publicRead)
141+
=> _publicReadAcl && publicRead ? S3CannedACL.PublicRead : null;
142+
128143
private string ResolveKey(string path) => _keyPrefix != null ? $"{_keyPrefix}/{path}" : path;
129144

130145
public void Dispose() => _s3Client.Dispose();

0 commit comments

Comments
 (0)