Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/provider-s3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ type ProviderConfig = {
* The time in seconds for the presigned URL to expire. By default, it is 24 hours.
*/
linkExpirationTime?: number;
/**
* If true, the provider will not sign requests and will try to access the S3 bucket without authentication.
*/
publicAccess?: boolean;
};
```

Expand Down Expand Up @@ -87,6 +91,22 @@ export default {
};
```

### Public S3 Bucket

```ts
// rock.config.mjs
import { providerS3 } from '@rock-js/provider-s3';

export default {
// ...
remoteCacheProvider: providerS3({
bucket: 'your-public-bucket',
region: 'your-region',
publicAccess: true, // Access public bucket without authentication
}),
};
```

## Documentation

For detailed documentation about Rock and its tools, visit [Rock Documentation](https://rockjs.dev)
53 changes: 53 additions & 0 deletions packages/provider-s3/src/__tests__/providerS3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,56 @@ test('providerS3 supports R2', async () => {
},
]);
});

test('providerS3 supports public access', async () => {
(clientS3.S3Client as ReturnType<typeof vi.fn>).mockClear();
const mockSend = (clientS3 as any).mockSend;
const mockStream = {
on: vi.fn((event, callback) => {
if (event === 'data') callback(Buffer.from('test data'));
if (event === 'end') callback();
return mockStream;
}),
};
mockSend.mockResolvedValueOnce({
Body: mockStream,
ContentLength: 9,
});

const cacheProvider = providerS3({
bucket: 'test-bucket',
region: 'us-east-1',
publicAccess: true,
})();

expect(clientS3.S3Client).toHaveBeenCalledWith(
expect.objectContaining({
region: 'us-east-1',
signer: expect.objectContaining({
sign: expect.any(Function),
}),
credentials: {
accessKeyId: '',
secretAccessKey: '',
},
}),
);

const s3ClientCall = (clientS3.S3Client as ReturnType<typeof vi.fn>).mock
.calls[0];
const signer = s3ClientCall[0].signer;
const mockRequest = { headers: {}, body: 'test' };
const signedRequest = await signer.sign(mockRequest);
expect(signedRequest).toBe(mockRequest);

const response = await cacheProvider.download({
artifactName: 'public-artifact',
});

expect(clientS3.GetObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: 'rock-artifacts/public-artifact.zip',
});
expect(mockSend).toHaveBeenCalled();
expect(response.headers.get('content-length')).toBe('9');
});
43 changes: 32 additions & 11 deletions packages/provider-s3/src/lib/providerS3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ type ProviderConfig = {
* External ID when assuming a role (for additional security).
*/
externalId?: string;
/**
* If true, the provider will not sign requests and will try to access the S3 bucket without authentication.
*/
publicAccess?: boolean;
};

export class S3BuildCache implements RemoteBuildCache {
Expand Down Expand Up @@ -104,6 +108,15 @@ export class S3BuildCache implements RemoteBuildCache {
} else if (config.profile) {
// Use shared config file (e.g. ~/.aws/credentials) with a profile
s3Config.credentials = fromIni({ profile: config.profile });
} else if (config.publicAccess) {
// Access the S3 bucket without authentication
s3Config.signer = {
sign: async (request) => request,
};
s3Config.credentials = {
accessKeyId: '',
secretAccessKey: '',
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Public S3 Access Fails with Signed URL Logic

The public access fallback configures empty credentials and a no-op signer, but the list() and upload() methods still call getSignedUrl which requires valid credentials to generate presigned URLs. This will fail or produce invalid URLs when accessing public S3 buckets without authentication. For public buckets, direct URLs should be constructed instead of using getSignedUrl.

Fix in Cursor Fix in Web

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication for upload/list is still mandatory, there is no workaround for that in S3

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: AWS SDK: Credential Fallback Broken

The public access fallback prevents the AWS SDK from using its default credential provider chain. When explicit credentials, roleArn, or profile aren't provided, the code sets empty credentials instead of leaving credentials undefined. This breaks documented support for AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment variables and IAM roles on EC2/ECS/Lambda, as the SDK can't fall back to these standard authentication methods.

Fix in Cursor Fix in Web

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on that, I think I should move this behind a config flag eg. publicAccess: boolean

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated with publicAccess

}

this.s3 = new clientS3.S3Client(s3Config);
Expand Down Expand Up @@ -185,17 +198,25 @@ export class S3BuildCache implements RemoteBuildCache {
}: {
artifactName: string;
}): Promise<Response> {
const res = await this.s3.send(
new clientS3.GetObjectCommand({
Bucket: this.bucket,
Key: `${this.directory}/${artifactName}.zip`,
}),
);
return new Response(toWebStream(res.Body as Readable), {
headers: {
'content-length': String(res.ContentLength),
},
});
try {
const res = await this.s3.send(
new clientS3.GetObjectCommand({
Bucket: this.bucket,
Key: `${this.directory}/${artifactName}.zip`,
}),
);
return new Response(toWebStream(res.Body as Readable), {
headers: {
'content-length': String(res.ContentLength),
},
});
} catch (error) {
if (this.config.publicAccess) {
(error as Error).message =
`Build not found or not accessible to the public`;
}
throw error;
}
}

async delete({
Expand Down
2 changes: 2 additions & 0 deletions website/src/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ In case you use a different env variable, you can pass it as a `accessKeyId` and
| `directory` | `string` | No | The directory to store artifacts in the S3 server (defaults to `rock-artifacts`) |
| `name` | `string` | No | The display name of the provider (defaults to `S3`) |
| `linkExpirationTime` | `number` | No | The time in seconds for presigned URLs to expire (defaults to 24 hours) |
| `publicAccess` | `boolean`| No | If true, the provider will not sign requests and will try to access the S3 bucket without authentication |

#### Authentication Methods

Expand All @@ -259,6 +260,7 @@ The S3 provider supports multiple authentication methods through the underlying
- **AWS credentials file**: Use `~/.aws/credentials` with the `profile` option
- **Role assumption**: Use `roleArn` to assume a different role, optionally with `profile` as source credentials
- **Temporary credentials**: Set `AWS_SESSION_TOKEN` environment variable for temporary credentials
- **Public access**: Set `publicAccess: true` to explicitly disable request signing and access public S3 buckets without authentication

#### Cloudflare R2

Expand Down