Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 2a05928

Browse files
committed
Add validation for internal R2 requests
Validate incoming R2 requests from `workerd` with `zod`, increasing type safety. This removes some implicit `any`s from `JSON.parse`, and allows us to use `method` as a type-level union discriminator. Validation of types has also been removed from the `Validator` class, as we don't need to duplicate this. `workerd` will handle validating user input here.
1 parent f8dd8dc commit 2a05928

File tree

9 files changed

+375
-464
lines changed

9 files changed

+375
-464
lines changed

package-lock.json

Lines changed: 8 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/tre/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"workerd": "^1.20230221.0",
4343
"ws": "^8.11.0",
4444
"youch": "^3.2.2",
45-
"zod": "^3.18.0"
45+
"zod": "^3.20.6"
4646
},
4747
"devDependencies": {
4848
"@cloudflare/workers-types": "^4.20221111.1",

packages/tre/src/plugins/r2/errors.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Response } from "../../http";
2+
import { HttpError } from "../../shared";
23
import { CfHeader } from "../shared/constants";
34
import { R2Object } from "./r2Object";
45

@@ -22,22 +23,18 @@ enum CfCode {
2223
InvalidRange = 10039,
2324
}
2425

25-
export class R2Error extends Error {
26-
status: number;
27-
v4Code: number;
26+
export class R2Error extends HttpError {
2827
object?: R2Object;
29-
constructor(status: number, message: string, v4Code: number) {
30-
super(message);
31-
this.name = "R2Error";
32-
this.status = status;
33-
this.v4Code = v4Code;
28+
29+
constructor(code: number, message: string, readonly v4Code: number) {
30+
super(code, message);
3431
}
3532

3633
toResponse() {
3734
if (this.object !== undefined) {
3835
const { metadataSize, value } = this.object.encode();
3936
return new Response(value, {
40-
status: this.status,
37+
status: this.code,
4138
headers: {
4239
[CfHeader.MetadataSize]: `${metadataSize}`,
4340
"Content-Type": "application/json",
@@ -51,7 +48,7 @@ export class R2Error extends Error {
5148
});
5249
}
5350
return new Response(null, {
54-
status: this.status,
51+
status: this.code,
5552
headers: {
5653
[CfHeader.Error]: JSON.stringify({
5754
message: this.message,

packages/tre/src/plugins/r2/gateway.ts

Lines changed: 27 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,43 @@
1+
import { z } from "zod";
12
import { Log } from "../../shared";
23
import { RangeStoredValueMeta, Storage } from "../../storage";
34
import { InvalidRange, NoSuchKey } from "./errors";
45
import {
5-
R2HTTPMetadata,
66
R2Object,
77
R2ObjectBody,
88
R2ObjectMetadata,
99
createVersion,
1010
} from "./r2Object";
11-
import { Validator } from "./validator";
12-
13-
// For more information, refer to https://datatracker.ietf.org/doc/html/rfc7232
14-
export interface R2Conditional {
15-
// Performs the operation if the object’s etag matches the given string.
16-
etagMatches?: string;
17-
// Performs the operation if the object’s etag does not match the given string.
18-
etagDoesNotMatch?: string;
19-
// Performs the operation if the object was uploaded before the given date.
20-
uploadedBefore?: number;
21-
// Performs the operation if the object was uploaded after the given date.
22-
uploadedAfter?: number;
23-
}
24-
25-
export interface R2Range {
26-
offset?: number;
27-
length?: number;
28-
suffix?: number;
29-
}
30-
31-
export interface R2GetOptions {
32-
// Specifies that the object should only be returned given satisfaction of
33-
// certain conditions in the R2Conditional. Refer to R2Conditional above.
34-
onlyIf?: R2Conditional;
35-
// Specifies that only a specific length (from an optional offset) or suffix
36-
// of bytes from the object should be returned. Refer to
37-
// https://developers.cloudflare.com/r2/runtime-apis/#ranged-reads.
38-
range?: R2Range;
39-
}
40-
41-
export interface R2PutOptions {
42-
// Various HTTP headers associated with the object. Refer to
43-
// https://developers.cloudflare.com/r2/runtime-apis/#http-metadata.
44-
httpMetadata: R2HTTPMetadata;
45-
// A map of custom, user-defined metadata that will be stored with the object.
46-
customMetadata: Record<string, string>;
47-
// A md5 hash to use to check the recieved object’s integrity.
48-
md5?: string;
49-
}
50-
51-
export type R2ListOptionsInclude = ("httpMetadata" | "customMetadata")[];
11+
import {
12+
R2GetRequestSchema,
13+
R2ListRequestSchema,
14+
R2PutRequestSchema,
15+
} from "./schemas";
16+
import { MAX_LIST_KEYS, Validator } from "./validator";
5217

53-
export interface R2ListOptions {
54-
// The number of results to return. Defaults to 1000, with a maximum of 1000.
55-
limit?: number;
56-
// The prefix to match keys against. Keys will only be returned if they start with given prefix.
57-
prefix?: string;
58-
// An opaque token that indicates where to continue listing objects from.
59-
// A cursor can be retrieved from a previous list operation.
60-
cursor?: string;
61-
// The character to use when grouping keys.
62-
delimiter?: string;
63-
// Can include httpFields and/or customFields. If included, items returned by
64-
// the list will include the specified metadata. Note that there is a limit on the
65-
// total amount of data that a single list operation can return.
66-
// If you request data, you may recieve fewer than limit results in your response
67-
// to accomodate metadata.
68-
// Use the truncated property to determine if the list request has more data to be returned.
69-
include?: R2ListOptionsInclude;
70-
}
18+
export type OmitRequest<T> = Omit<T, "method" | "object">;
19+
export type R2GetOptions = OmitRequest<z.infer<typeof R2GetRequestSchema>>;
20+
export type R2PutOptions = OmitRequest<z.infer<typeof R2PutRequestSchema>>;
21+
export type R2ListOptions = OmitRequest<z.infer<typeof R2ListRequestSchema>>;
7122

7223
export interface R2Objects {
7324
// An array of objects matching the list request.
7425
objects: R2Object[];
75-
// If true, indicates there are more results to be retrieved for the current list request.
26+
// If true, indicates there are more results to be retrieved for the current
27+
// list request.
7628
truncated: boolean;
77-
// A token that can be passed to future list calls to resume listing from that point.
29+
// A token that can be passed to future list calls to resume listing from that
30+
// point.
7831
// Only present if truncated is true.
7932
cursor?: string;
80-
// If a delimiter has been specified, contains all prefixes between the specified
81-
// prefix and the next occurence of the delimiter.
82-
// For example, if no prefix is provided and the delimiter is ‘/’, foo/bar/baz
83-
// would return foo as a delimited prefix. If foo/ was passed as a prefix
84-
// with the same structure and delimiter, foo/bar would be returned as a delimited prefix.
33+
// If a delimiter has been specified, contains all prefixes between the
34+
// specified prefix and the next occurrence of the delimiter. For example, if
35+
// no prefix is provided and the delimiter is "/", "foo/bar/baz" would return
36+
// "foo" as a delimited prefix. If "foo/" was passed as a prefix with the same
37+
// structure and delimiter, "foo/bar" would be returned as a delimited prefix.
8538
delimitedPrefixes: string[];
8639
}
8740

88-
const MAX_LIST_KEYS = 1_000;
89-
// https://developers.cloudflare.com/r2/platform/limits/ (5GB - 5MB)
90-
9141
const validate = new Validator();
9242

9343
export class R2Gateway {
@@ -110,10 +60,7 @@ export class R2Gateway {
11060
options: R2GetOptions = {}
11161
): Promise<R2ObjectBody | R2Object> {
11262
const { range = {}, onlyIf } = options;
113-
validate
114-
.key(key)
115-
.getOptions(options)
116-
.condition(await this.head(key), onlyIf);
63+
validate.key(key).condition(await this.head(key), onlyIf);
11764

11865
let stored: RangeStoredValueMeta<R2ObjectMetadata> | undefined;
11966

@@ -140,22 +87,18 @@ export class R2Gateway {
14087
): Promise<R2Object> {
14188
const { customMetadata, md5, httpMetadata } = options;
14289

143-
const hash = validate
144-
.key(key)
145-
.putOptions(options)
146-
.size(value)
147-
.md5(value, md5);
90+
const hash = validate.key(key).size(value).md5(value, md5);
14891

14992
// build metadata
15093
const metadata: R2ObjectMetadata = {
15194
key,
15295
size: value.byteLength,
153-
etag: hash,
96+
etag: hash.toString("hex"),
15497
version: createVersion(),
15598
httpEtag: `"${hash}"`,
15699
uploaded: Date.now(),
157-
httpMetadata,
158-
customMetadata,
100+
httpMetadata: httpMetadata ?? {},
101+
customMetadata: customMetadata ?? {},
159102
};
160103

161104
// Store value with expiration and metadata
@@ -173,9 +116,7 @@ export class R2Gateway {
173116
}
174117

175118
async list(listOptions: R2ListOptions = {}): Promise<R2Objects> {
176-
const delimitedPrefixes = new Set<string>();
177-
178-
validate.listOptions(listOptions);
119+
validate.limit(listOptions.limit);
179120

180121
const { prefix = "", include = [], cursor = "" } = listOptions;
181122
let { delimiter, limit = MAX_LIST_KEYS } = listOptions;
@@ -190,8 +131,7 @@ export class R2Gateway {
190131
cursor,
191132
delimiter,
192133
});
193-
// add delimited prefixes should they exist
194-
for (const dP of res.delimitedPrefixes ?? []) delimitedPrefixes.add(dP);
134+
const delimitedPrefixes = new Set(res.delimitedPrefixes ?? []);
195135

196136
const objects = res.keys
197137
// grab metadata

packages/tre/src/plugins/r2/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ export const R2_PLUGIN: Plugin<
4545

4646
export * from "./r2Object";
4747
export * from "./gateway";
48+
export * from "./schemas";

0 commit comments

Comments
 (0)