Skip to content

Implement no credential R2 binding mount support#691

Open
scuffi wants to merge 16 commits into
mainfrom
patch/add-no-credential-r2-mounts
Open

Implement no credential R2 binding mount support#691
scuffi wants to merge 16 commits into
mainfrom
patch/add-no-credential-r2-mounts

Conversation

@scuffi
Copy link
Copy Markdown
Contributor

@scuffi scuffi commented May 12, 2026

Summary

This PR implements a new mount path for R2 bindings that never passes credentials into the container. Instead of supplying an S3-compatible endpoint and access keys, users pass a bindingName referencing an R2 binding on their Worker. The DO intercepts s3fs HTTP traffic at the Worker boundary, translates S3 API calls into native R2 binding calls, and proxies the result back — keeping credentials entirely in the Worker runtime.

How it works

  1. When mountBucket() detects no endpoint on the options, it takes the R2 egress path.
  2. A one-time password file is written to /tmp/.passwd-s3fs-<uuid> inside the container (dummy credentials — s3fs requires them syntactically, but they are never validated by R2).
  3. s3fs is launched pointing at http://r2.internal/<bucket> (not HTTPS, meaning interceptHttps is not required for this feature).
  4. Outbound traffic to r2.internal is intercepted by the new R2 egress handler, which:
    • Validates the bucket name against an allowlist of mounted buckets.
    • Enforces readOnly by rejecting mutating operations with 403.
    • Translates S3 path-style operations (list, get, put, delete, multipart, copy, range) to native R2 binding API calls.
    • Returns S3-compatible XML responses that s3fs can parse.
  5. On unmount or sandbox destroy, the password file and outbound handler registration are cleaned up.

Speed comparison R2 binding vs S3 native

The R2 path does introduce a slight overhead for most operations (mostly negligible ~few ms), except for writes. We have a pretty noticeable overhead for write operations through R2 bindings (~1.5x-2x slower). I'm yet to find an approach to speed this up, so this will likely be come back to in future.

Operation R2 (ms) S3 (ms) Delta Winner
Mount bucket 2270 8406 -6136ms R2
List directory (initial) 560 594 -34ms R2
Read seeded sample file 145 158 -13ms R2
Stat sample file (exec) 42 54 -12ms R2
Write tiny file (~150 B) 1703 1430 +273ms S3
Read tiny file back 167 186 -19ms R2
Write 1 KB file 1788 1367 +421ms S3
Read 1 KB file back 361 366 -5ms R2
Write 10 KB file 1366 663 +703ms S3
Read 10 KB file back 420 273 +147ms S3
Write 100 KB file 1416 735 +681ms S3
Read 100 KB file back 324 283 +41ms S3
Write 512 KB file 1405 567 +838ms S3
Read 512 KB file back 944 382 +562ms S3
Overwrite test: write V1 1626 1319 +307ms S3
Overwrite test: replace with V2 1406 306 +1100ms S3
Overwrite test: read back 167 133 +34ms S3
Concurrent write 3 × 2 KB files 4340 2366 +1974ms S3
Concurrent read 3 files back 890 816 +74ms S3
Check file exists 99 82 +17ms S3
List directory (post-write) 534 704 -170ms R2
Delete written files (exec rm) 1952 1998 -46ms R2
Unmount bucket 173 160 +13ms S3

New usage

SDK (production — R2 binding, no credentials):

await sandbox.mountBucket('MY_BUCKET', '/mnt/data');

// with options:
await sandbox.mountBucket('MY_BUCKET', '/mnt/data', {
  prefix: '/uploads',
  readOnly: true,
  s3fsOptions: ['parallel_count=16'],
});

SDK (existing — remote S3/R2 endpoint with explicit credentials, unchanged):

await sandbox.mountBucket('my-bucket', '/mnt/data', {
  endpoint: 'https://<account>.r2.cloudflarestorage.com',
  credentials: { accessKeyId: '...', secretAccessKey: '...' },
});

Bridge HTTP API:

// POST /v1/sandbox/:id/mount
{
  "bucket": "MY_BUCKET",       // binding name — no endpoint means R2 egress path
  "mountPath": "/mnt/data",
  "options": {
    "prefix": "/uploads",      // optional — must start with /
    "readOnly": false,
    "s3fsOptions": ["parallel_count=8"]
  }
}

Bridge types

The old bridge-specific mount option types were replaced with a direct union alias over the SDK's own MountBucketOptions. This removes the impedance mismatch between the bridge API surface and the SDK:

  • MountBucketRequestOptions is now a union of RemoteMountBucketOptions | R2BindingMountBucketOptions | LocalMountBucketOptions — the same union exported from the SDK package.
  • Mount option validation and conversion to SDK types are centralised in routes.ts — all type narrowing happens once before sandbox.mountBucket() is called.
  • Input validation now explicitly rejects: non-string endpoint, non-object or array options, non-string credentials.accessKeyId / secretAccessKey, non-boolean readOnly, non-string prefix.

Files changed

File Change
packages/sandbox/src/storage-mount/r2-egress-handler.ts New — full R2 egress HTTP handler: list, get, put, delete, multipart, copy, range requests, read-only enforcement, S3-compatible XML serialisation
packages/sandbox/src/sandbox.ts New mountBucketR2Egress() path; password file lifecycle; outbound handler registration and cleanup; mountpoint check enforcement; unmount/destroy cleanup
packages/sandbox/src/bridge/types.ts Replaced bridge-specific mount option types with SDK union alias
packages/sandbox/src/bridge/routes.ts Centralised validateMountOptions() and toSDKMountOptions() helpers; tightened primitive type validation
packages/sandbox/src/bridge/openapi.ts Updated s3fsOptions description to cover both remote and R2 mounts; fixed prefix description
packages/sandbox/src/storage-mount/validation.ts Bucket name, binding name, and prefix validators; isR2BindingMount type guard
packages/sandbox/src/storage-mount/types.ts Added R2EgressMountInfo discriminant to the MountInfo union
packages/sandbox/src/storage-mount/index.ts Re-exports for new types
packages/shared/src/types.ts Added R2BindingMountBucketOptions; updated MountBucketOptions union
bridge/worker/README.md Documented R2 binding mount, s3fsOptions, prefix, and readOnly in the mount API reference
packages/sandbox/tests/r2-egress-handler.test.ts 28 unit tests for egress handler operations
packages/sandbox/tests/r2-egress-mount.test.ts 11 unit tests for mount lifecycle, prefix handling, failure cleanup, protected option rejection, and mountpoint check enforcement
packages/sandbox/tests/storage-mount/validation.test.ts 27 unit tests for the new validators
bridge/worker/src/__tests__/mount.test.ts Extended with malformed credential, invalid primitive, and array options rejection tests
tests/e2e/bucket-mounting.test.ts E2E test: mount via R2 binding, verify pre-existing R2 file is visible in the container, write a file from the container, verify it appears in R2, then unmount and confirm mountpoint is cleaned up

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 12, 2026

🦋 Changeset detected

Latest commit: 0bdabec

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/sandbox Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/sandbox-sdk/@cloudflare/sandbox@691

commit: 0bdabec

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 12, 2026

🐳 Docker Images Published

Variant Image
Default cloudflare/sandbox:0.0.0-pr-691-0bdabec
Python cloudflare/sandbox:0.0.0-pr-691-0bdabec-python
OpenCode cloudflare/sandbox:0.0.0-pr-691-0bdabec-opencode
Musl cloudflare/sandbox:0.0.0-pr-691-0bdabec-musl
Desktop cloudflare/sandbox:0.0.0-pr-691-0bdabec-desktop

Usage:

FROM cloudflare/sandbox:0.0.0-pr-691-0bdabec

Version: 0.0.0-pr-691-0bdabec


📦 Standalone Binary

For arbitrary Dockerfiles:

COPY --from=cloudflare/sandbox:0.0.0-pr-691-0bdabec /container-server/sandbox /sandbox
ENTRYPOINT ["/sandbox"]

Download via GitHub CLI:

gh run download 26043428471 -n sandbox-binary

Extract from Docker:

docker run --rm cloudflare/sandbox:0.0.0-pr-691-0bdabec cat /container-server/sandbox > sandbox && chmod +x sandbox

github-advanced-security[bot]

This comment was marked as resolved.

@scuffi
Copy link
Copy Markdown
Contributor Author

scuffi commented May 13, 2026

/bonk review this PR

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: Implement no credential R2 binding mount support

This is a well-structured feature implementation with clear separation between the egress handler, mount lifecycle, bridge integration, and validation. The architecture is sound — intercepting s3fs egress traffic at the Worker boundary and translating to native R2 binding calls is elegant and keeps credentials entirely out of the container.

Strengths

  • Clean architecture: The egress handler is self-contained with no module-level mutable state; bucket params are serialized through setOutboundByHost so they survive isolate boundaries.
  • Thorough error handling: Mount failure cleanup (password file, outbound handler deregistration) is handled correctly in catch blocks. The mountpoint verification post-s3fs is a good safety net.
  • Good test coverage: 28 handler tests, 11 mount lifecycle tests, 27 validation tests, plus E2E coverage. The mock R2Bucket factory is well-implemented.
  • Bridge refactoring: Replacing the hand-rolled bridge mount types with a union alias over the SDK types eliminates type drift and centralizes validation — nice improvement.

Issues

1. PR description says nosignrequest but the code uses dummy credentials

The PR description states: "s3fs is launched pointing at http://r2.internal/<bucket> with nosignrequest so it never attempts to sign requests." However, nosignrequest is never passed as an s3fs option anywhere in the code. Instead, the implementation writes dummy credentials (x:x) to a passwd file. This actually works (the credentials are never validated by the egress handler), but the description is inaccurate and should be updated to match the implementation.

2. CodeQL alerts — polynomial regex on parseCompleteMultipartUploadBody

CodeQL flagged two polynomial regex patterns. The <Part> regex at line 244 operates on user-provided XML from the request body. While this runs in the Worker (not exposed to the public internet), the request body originates from s3fs inside the container which is generally trusted. However, the /<Part>.*<\/Part>/ pattern could be slow on pathological input. The current implementation using indexOf + slice is actually safe — the CodeQL alert appears to be about the inner regexes (/<PartNumber>(\d+)<\/PartNumber>/ and /<ETag>("?[^<]+"?)<\/ETag>/). These run on bounded segments extracted by the indexOf loop, so the risk is low, but it may be worth adding a brief comment explaining why this is safe to suppress the alert.

3. handleGetObject buffers the entire object into memory via arrayBuffer()

At line 416, handleGetObject calls await obj.arrayBuffer() which buffers the entire R2 object into memory before returning it. For large files, this could cause memory pressure in the Worker. Streaming the R2 response body directly would be more memory-efficient:

// Instead of:
const body = await obj.arrayBuffer();
return new Response(body, { ... });

// Consider:
return new Response(obj.body, { ... });

The same concern applies to the range request path (line 431) and copy source path (line 491). This is a performance concern, not a correctness issue — it works, but Workers have a 128MB memory limit.

4. R2BindingMountBucketOptions.endpoint?: never doesn't prevent runtime assignment

In packages/shared/src/types.ts:1308, endpoint?: never provides compile-time safety for TypeScript consumers but doesn't prevent bridge/HTTP API callers from passing { endpoint: "" }. The mount dispatch logic in sandbox.ts:1199 checks !remoteOptions.endpoint which is falsy for empty string — this correctly falls through to R2 egress, but an empty string endpoint might be confusing. Consider explicitly checking endpoint === undefined or documenting that empty strings are treated as "no endpoint".

5. getR2EgressParams rebuilds the full params map on every call

getR2EgressParams() iterates all active mounts to collect R2 egress entries. It's called during mount, unmount, error cleanup, and destroy. With many mounts this is fine (mounts are typically few), but consider caching or storing the R2 params separately if this becomes a hot path.

6. handlePutObject copy-source prefix application is asymmetric

At line 477-479, the source key gets the mount prefix prepended only when copySource.bucket === bucketName. But if the copy source is a different bucket that also has an R2 egress mount with its own prefix, that prefix is not applied. This is because mountPrefix is per-bucket-per-request and the handler only has the current bucket's prefix. This is acceptable for cross-bucket copies where the source should use its raw key, but worth documenting the semantics.

7. Missing nosignrequest or equivalent — s3fs may still attempt signing

The R2 egress handler never validates signatures (which is correct), but s3fs may still attempt to sign requests using the dummy credentials. The x:x dummy credentials make this work because the signature just gets ignored by the handler. This is a pragmatic workaround, but worth a code comment explaining why dummy credentials are needed syntactically.

Nits

  • r2-egress-handler.ts:67-71: trimTrailingSlashes is a nice utility but it's only used once (line 622). Consider inlining or leaving as-is — not a blocker.
  • sandbox.ts:1338: const prefix = options.prefix || undefinedoptions.prefix is already string | undefined, so || undefined converts empty string to undefined. If that's intentional behavior, a brief comment would help clarify.
  • r2-egress-mount.test.ts:341: The test uses as any for the options argument to bypass TypeScript's endpoint?: never check. This is fine for testing but worth noting that the type guard works correctly at the SDK API level.
  • The s3fsOptions description in openapi.ts:127 says "Only applies to R2 binding mounts (when endpoint is omitted)" but s3fsOptions is also accepted for remote mounts. The description should reflect both.

ask-bonk[bot]

This comment was marked as resolved.

ask-bonk[bot]

This comment was marked as resolved.

ask-bonk[bot]

This comment was marked as resolved.

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 13, 2026

Review posted on PR #691. Here's a summary of the findings:

Overall: Solid feature implementation with clean architecture. The egress interception approach is well-designed, keeping credentials entirely out of the container while leveraging the existing s3fs-FUSE infrastructure.

Key issues raised:

  1. Memory buffering in handleGetObject — The handler calls arrayBuffer() which buffers entire R2 objects in Worker memory. Should stream obj.body directly to avoid hitting the 128MB memory limit on large files.

  2. PR description inaccuracy — Claims nosignrequest is used but the code actually uses dummy credentials (x:x) via passwd file. The description should be updated.

  3. OpenAPI s3fsOptions description — Says "Only applies to R2 binding mounts" but s3fsOptions is accepted for remote mounts too.

  4. Minor nits — Empty string prefix coercion (|| undefined) could use a clarifying comment; endpoint?: never doesn't prevent runtime empty string assignment.

4 inline comments were posted on the specific lines of code.

github run

@scuffi scuffi force-pushed the patch/add-no-credential-r2-mounts branch from b7e82b6 to 1325150 Compare May 14, 2026 12:08
Copy link
Copy Markdown
Contributor

@aron-cf aron-cf left a comment

Choose a reason for hiding this comment

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

I'm halfway, but wanted to give some feedback while I review the egress handler. This is looking really good. I like the API and the structure.

I've got two major thoughts:

  1. We need to figure out how to implement the egress handler without touching the public egress methods. Currently it's very easy for a user to wipe out the handler and break the implementation. Ideally we need to isolate our handler.
  2. I think we need a clearer distinction between bucket and binding. Having both represented as strings isn't ideal. If you pass the binding directly into the mount/unmount functions it would remove the ambiguity. Are there benefits to also supporting the string I'm missing?

Comment thread packages/sandbox/src/storage-mount/types.ts Outdated
Comment thread packages/sandbox/src/storage-mount/validation.ts
Comment thread packages/sandbox/src/sandbox.ts
Comment thread packages/sandbox/src/sandbox.ts Outdated
Comment thread packages/sandbox/src/sandbox.ts Outdated
Comment thread packages/sandbox/src/bridge/openapi.ts Outdated
Copy link
Copy Markdown
Contributor

@aron-cf aron-cf left a comment

Choose a reason for hiding this comment

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

Okay, left some comments on the egress handler too.

Comment thread packages/sandbox/src/storage-mount/r2-egress-handler.ts Outdated
Comment thread packages/sandbox/src/storage-mount/r2-egress-handler.ts Outdated
Comment thread packages/sandbox/src/storage-mount/r2-egress-handler.ts Outdated
Comment thread packages/sandbox/src/storage-mount/r2-egress-handler.ts
Comment thread packages/sandbox/src/storage-mount/r2-egress-handler.ts Outdated
Comment thread packages/sandbox/src/storage-mount/r2-egress-handler.ts Outdated
Comment thread packages/sandbox/src/storage-mount/r2-egress-handler.ts
@scuffi scuffi force-pushed the patch/add-no-credential-r2-mounts branch from a619113 to dcd5114 Compare May 15, 2026 10:12
@scuffi scuffi force-pushed the patch/add-no-credential-r2-mounts branch from dcd5114 to 6940567 Compare May 15, 2026 10:27
@aron-cf aron-cf marked this pull request as ready for review May 15, 2026 11:07
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

⚠️ 1 issue in files not directly in the diff

⚠️ R2BindingMountBucketOptions not re-exported from public SDK package (packages/sandbox/src/index.ts:45-52)

R2BindingMountBucketOptions is exported from @repo/shared (packages/shared/src/index.ts:198) but is NOT re-exported from @cloudflare/sandbox in packages/sandbox/src/index.ts:22-62. Both other variants of MountBucketOptionsLocalMountBucketOptions (line 45) and RemoteMountBucketOptions (line 52) — are exported. This breaks the established pattern and prevents SDK consumers from referencing the new R2 binding mount type by name when narrowing MountBucketOptions.

View 6 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

@aron-cf aron-cf left a comment

Choose a reason for hiding this comment

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

This is getting really close. Well done fixing that egress issue. It looks like some tests are failing now though.

Comment thread packages/sandbox/src/sandbox.ts Outdated
Comment on lines +200 to +204
const R2_DEFAULT_S3FS_OPTIONS: readonly string[] = [
'stat_cache_expire=60',
'enable_noobj_cache',
'multipart_size=5'
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In the container side I refactored this to handle options as an object. It makes it much easier to work with.

{
  stat_cache_expire: "60",
  enable_noobj_cache: true,
  multipart_size: "5",
}

Then just serialize the object when needed (true become single strings only, false is dropped).

Copy link
Copy Markdown
Collaborator

@whoiskatrin whoiskatrin May 15, 2026

Choose a reason for hiding this comment

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

really like this shape! (re: object)

Comment thread packages/sandbox/src/sandbox.ts Outdated
Comment on lines +1970 to +1994
} else if (mountInfo.mountType === 'r2-egress') {
if (mountInfo.mounted) {
try {
this.logger.debug(
`Unmounting R2 egress bucket ${mountInfo.bucket} from ${mountPath}`
);
const result = await this.execInternal(
`fusermount -u ${shellEscape(mountPath)}`
);
if (result.exitCode !== 0) {
throw new Error(
`fusermount -u failed (exit ${result.exitCode}): ${result.stderr || 'unknown error'}`
);
}
mountInfo.mounted = false;
} catch (error) {
mountFailures++;
const errorMsg =
error instanceof Error ? error.message : String(error);
this.logger.warn(
`Failed to unmount R2 egress bucket ${mountInfo.bucket} from ${mountPath}: ${errorMsg}`
);
}
}
await this.deletePasswordFile(mountInfo.passwordFilePath);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This still duplicates most of the following else block.

Comment on lines +6148 to +6165
const fetcher = ctx.exports.ContainerProxy({
props: {
enableInternet: this.enableInternet,
containerId: this.ctx.id.toString(),
className: R2_EGRESS_PROXY_TARGET_CLASS_NAME,
outboundByHostOverrides: {
'r2.internal': {
method: 'r2EgressMount',
params
}
}
}
});
if (!isFetcher(fetcher)) {
throw new InvalidMountConfigError(
'R2 binding mounts require ContainerProxy to return a valid Fetcher'
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a nice fix!

Comment thread packages/sandbox/src/sandbox.ts Outdated
}

private async configureR2EgressOutbound(
params: R2EgressParams = this.getR2EgressParams()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a bit weird, I think I'd prefer it to always just be passed params for clarity at the call site.

Comment thread packages/sandbox/src/sandbox.ts Outdated
Comment on lines +6171 to +6172
const remainingR2Params = this.getR2EgressParams();
await this.configureR2EgressOutbound(remainingR2Params);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is equivalent to just calling configureR2EgressOutbound() no?

});
}
const upload = r2.resumeMultipartUpload(key, uploadId);
const body = await readRequestBody(request);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This can't do this. Can we not make this work with streams? As we're acting as a proxy, I'm assuming the request headers must contain the data we need about content length and etag etc?

Comment thread bridge/worker/README.md Outdated
Comment on lines +274 to +275
For compatibility, `bucket` is also accepted as the binding name when `binding`
is omitted and `options.endpoint` is not provided.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We don't need compatibility, this is a new feature.

@scuffi scuffi force-pushed the patch/add-no-credential-r2-mounts branch from 5dce680 to 6f11de0 Compare May 15, 2026 13:27
@scuffi scuffi force-pushed the patch/add-no-credential-r2-mounts branch from a5a6b0c to 72d86e1 Compare May 15, 2026 14:18
@scuffi scuffi force-pushed the patch/add-no-credential-r2-mounts branch from 72d86e1 to 728f9ec Compare May 15, 2026 14:27
@scuffi scuffi force-pushed the patch/add-no-credential-r2-mounts branch from f86c510 to 5b580f9 Compare May 15, 2026 14:43
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/sandbox/src/storage-mount/r2-egress-handler.ts Outdated
Comment thread packages/sandbox/src/sandbox.ts
@scuffi scuffi force-pushed the patch/add-no-credential-r2-mounts branch from 348bae5 to 0bdabec Compare May 18, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants