@kodelyth/tlon 2026.5.42 → 2026.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/klaw.plugin.json +203 -3
- package/package.json +19 -6
- package/api.ts +0 -16
- package/channel-plugin-api.ts +0 -1
- package/doctor-contract-api.ts +0 -1
- package/index.ts +0 -16
- package/runtime-api.ts +0 -17
- package/setup-api.ts +0 -2
- package/setup-entry.ts +0 -9
- package/src/account-fields.ts +0 -31
- package/src/channel.message-adapter.test.ts +0 -145
- package/src/channel.runtime.ts +0 -259
- package/src/channel.ts +0 -192
- package/src/config-schema.ts +0 -54
- package/src/core.test.ts +0 -298
- package/src/doctor-contract.ts +0 -9
- package/src/doctor.test.ts +0 -46
- package/src/doctor.ts +0 -10
- package/src/logger-runtime.ts +0 -1
- package/src/monitor/approval-runtime.ts +0 -363
- package/src/monitor/approval.test.ts +0 -33
- package/src/monitor/approval.ts +0 -283
- package/src/monitor/authorization.ts +0 -30
- package/src/monitor/cites.ts +0 -54
- package/src/monitor/discovery.ts +0 -68
- package/src/monitor/history.ts +0 -226
- package/src/monitor/index.ts +0 -1523
- package/src/monitor/media.test.ts +0 -80
- package/src/monitor/media.ts +0 -156
- package/src/monitor/processed-messages.test.ts +0 -58
- package/src/monitor/processed-messages.ts +0 -89
- package/src/monitor/settings-helpers.test.ts +0 -113
- package/src/monitor/settings-helpers.ts +0 -158
- package/src/monitor/utils.ts +0 -402
- package/src/runtime.ts +0 -9
- package/src/security.test.ts +0 -658
- package/src/session-route.ts +0 -40
- package/src/settings.ts +0 -391
- package/src/setup-core.ts +0 -231
- package/src/setup-surface.ts +0 -99
- package/src/targets.ts +0 -102
- package/src/tlon-api.test.ts +0 -572
- package/src/tlon-api.ts +0 -389
- package/src/types.ts +0 -160
- package/src/urbit/auth.ssrf.test.ts +0 -45
- package/src/urbit/auth.ts +0 -48
- package/src/urbit/base-url.test.ts +0 -48
- package/src/urbit/base-url.ts +0 -61
- package/src/urbit/channel-ops.test.ts +0 -36
- package/src/urbit/channel-ops.ts +0 -149
- package/src/urbit/context.ts +0 -50
- package/src/urbit/errors.ts +0 -51
- package/src/urbit/fetch.ts +0 -38
- package/src/urbit/foreigns.ts +0 -49
- package/src/urbit/send.test.ts +0 -83
- package/src/urbit/send.ts +0 -228
- package/src/urbit/sse-client.test.ts +0 -234
- package/src/urbit/sse-client.ts +0 -492
- package/src/urbit/story.ts +0 -332
- package/src/urbit/upload.test.ts +0 -155
- package/src/urbit/upload.ts +0 -60
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
package/src/tlon-api.test.ts
DELETED
|
@@ -1,572 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { authenticate } from "./urbit/auth.js";
|
|
3
|
-
import { scryUrbitPath } from "./urbit/channel-ops.js";
|
|
4
|
-
|
|
5
|
-
const { mockFetchGuard, mockRelease, mockGetSignedUrl } = vi.hoisted(() => ({
|
|
6
|
-
mockFetchGuard: vi.fn(),
|
|
7
|
-
mockRelease: vi.fn(async () => {}),
|
|
8
|
-
mockGetSignedUrl: vi.fn(),
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
vi.mock("klaw/plugin-sdk/ssrf-runtime", async () => {
|
|
12
|
-
const original = (await vi.importActual("klaw/plugin-sdk/ssrf-runtime")) as Record<
|
|
13
|
-
string,
|
|
14
|
-
unknown
|
|
15
|
-
>;
|
|
16
|
-
return {
|
|
17
|
-
...original,
|
|
18
|
-
fetchWithSsrFGuard: mockFetchGuard,
|
|
19
|
-
};
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
vi.mock("@aws-sdk/s3-request-presigner", () => ({
|
|
23
|
-
getSignedUrl: mockGetSignedUrl,
|
|
24
|
-
}));
|
|
25
|
-
|
|
26
|
-
vi.mock("./urbit/auth.js", () => ({
|
|
27
|
-
authenticate: vi.fn(),
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
vi.mock("./urbit/channel-ops.js", () => ({
|
|
31
|
-
scryUrbitPath: vi.fn(),
|
|
32
|
-
}));
|
|
33
|
-
|
|
34
|
-
import { fetchWithSsrFGuard } from "klaw/plugin-sdk/ssrf-runtime";
|
|
35
|
-
import { configureClient, uploadFile } from "./tlon-api.js";
|
|
36
|
-
|
|
37
|
-
const mockAuthenticate = vi.mocked(authenticate);
|
|
38
|
-
const mockScryUrbitPath = vi.mocked(scryUrbitPath);
|
|
39
|
-
const mockGuardedFetch = vi.mocked(fetchWithSsrFGuard);
|
|
40
|
-
|
|
41
|
-
function createMemexResponse(
|
|
42
|
-
uploadUrl: string,
|
|
43
|
-
filePath = "https://memex.tlon.network/files/uploaded.png",
|
|
44
|
-
): Response {
|
|
45
|
-
return new Response(
|
|
46
|
-
JSON.stringify({
|
|
47
|
-
url: uploadUrl,
|
|
48
|
-
filePath,
|
|
49
|
-
}),
|
|
50
|
-
{
|
|
51
|
-
status: 200,
|
|
52
|
-
headers: { "content-type": "application/json" },
|
|
53
|
-
},
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function createGuardedResult(response: Response, finalUrl: string) {
|
|
58
|
-
return {
|
|
59
|
-
response,
|
|
60
|
-
finalUrl,
|
|
61
|
-
release: mockRelease,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function guardedFetchCall(index: number): Parameters<typeof fetchWithSsrFGuard>[0] {
|
|
66
|
-
const call = mockGuardedFetch.mock.calls[index]?.at(0);
|
|
67
|
-
if (call === undefined) {
|
|
68
|
-
throw new Error(`expected guarded fetch call ${index}`);
|
|
69
|
-
}
|
|
70
|
-
return call;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
describe("uploadFile memex upload hardening", () => {
|
|
74
|
-
beforeEach(() => {
|
|
75
|
-
vi.clearAllMocks();
|
|
76
|
-
vi.stubGlobal("fetch", vi.fn());
|
|
77
|
-
mockAuthenticate.mockResolvedValue("urbauth-~zod=fake-cookie");
|
|
78
|
-
configureClient({
|
|
79
|
-
shipUrl: "https://groups.tlon.network",
|
|
80
|
-
shipName: "~zod",
|
|
81
|
-
verbose: false,
|
|
82
|
-
getCode: async () => "123456",
|
|
83
|
-
});
|
|
84
|
-
mockScryUrbitPath.mockImplementation(async (_deps, params) => {
|
|
85
|
-
if (params.path === "/storage/configuration.json") {
|
|
86
|
-
return {
|
|
87
|
-
currentBucket: "uploads",
|
|
88
|
-
buckets: ["uploads"],
|
|
89
|
-
publicUrlBase: "https://files.tlon.network/",
|
|
90
|
-
presignedUrl: "https://files.tlon.network/presigned",
|
|
91
|
-
region: "us-east-1",
|
|
92
|
-
service: "presigned-url",
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
if (params.path === "/storage/credentials.json") {
|
|
96
|
-
return { "storage-update": {} };
|
|
97
|
-
}
|
|
98
|
-
if (params.path === "/genuine/secret.json") {
|
|
99
|
-
return { secret: "genuine-secret" };
|
|
100
|
-
}
|
|
101
|
-
throw new Error(`Unexpected scry path: ${params.path}`);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
afterEach(() => {
|
|
106
|
-
vi.unstubAllGlobals();
|
|
107
|
-
vi.restoreAllMocks();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("routes the memex upload URL through the SSRF guard", async () => {
|
|
111
|
-
mockGuardedFetch
|
|
112
|
-
.mockResolvedValueOnce(
|
|
113
|
-
createGuardedResult(
|
|
114
|
-
createMemexResponse("https://uploads.tlon.network/put"),
|
|
115
|
-
"https://memex.tlon.network/v1/zod/upload",
|
|
116
|
-
),
|
|
117
|
-
)
|
|
118
|
-
.mockResolvedValueOnce(
|
|
119
|
-
createGuardedResult(
|
|
120
|
-
new Response(null, { status: 200 }),
|
|
121
|
-
"https://uploads.tlon.network/put",
|
|
122
|
-
),
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const result = await uploadFile({
|
|
126
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
127
|
-
fileName: "avatar.png",
|
|
128
|
-
contentType: "image/png",
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
expect(result).toEqual({ url: "https://memex.tlon.network/files/uploaded.png" });
|
|
132
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
133
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
|
|
134
|
-
const firstCall = guardedFetchCall(0);
|
|
135
|
-
expect(firstCall?.url).toBe("https://memex.tlon.network/v1/zod/upload");
|
|
136
|
-
expect(firstCall?.init?.method).toBe("PUT");
|
|
137
|
-
expect(firstCall?.init?.headers).toEqual({ "Content-Type": "application/json" });
|
|
138
|
-
expect(firstCall?.auditContext).toBe("tlon-memex-upload-url");
|
|
139
|
-
expect(firstCall?.capture).toBe(false);
|
|
140
|
-
expect(firstCall?.maxRedirects).toBe(0);
|
|
141
|
-
const firstBodyRaw = firstCall?.init?.body;
|
|
142
|
-
expect(typeof firstBodyRaw).toBe("string");
|
|
143
|
-
const firstBody = JSON.parse(firstBodyRaw as string) as Record<string, unknown>;
|
|
144
|
-
expect(firstBody.token).toBe("genuine-secret");
|
|
145
|
-
expect(firstBody.contentLength).toBe(11);
|
|
146
|
-
expect(firstBody.contentType).toBe("image/png");
|
|
147
|
-
expect(typeof firstBody.fileName).toBe("string");
|
|
148
|
-
const secondCall = guardedFetchCall(1);
|
|
149
|
-
expect(secondCall?.url).toBe("https://uploads.tlon.network/put");
|
|
150
|
-
expect(secondCall?.init?.method).toBe("PUT");
|
|
151
|
-
expect(secondCall?.init?.headers).toEqual({
|
|
152
|
-
"Cache-Control": "public, max-age=3600",
|
|
153
|
-
"Content-Type": "image/png",
|
|
154
|
-
});
|
|
155
|
-
expect(secondCall?.auditContext).toBe("tlon-memex-upload");
|
|
156
|
-
expect(secondCall?.capture).toBe(false);
|
|
157
|
-
expect(secondCall?.maxRedirects).toBe(0);
|
|
158
|
-
expect(secondCall?.init?.body).toBeInstanceOf(Blob);
|
|
159
|
-
expect(mockRelease).toHaveBeenCalledTimes(2);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("surfaces guarded upload failures for hosted Memex targets", async () => {
|
|
163
|
-
mockGuardedFetch
|
|
164
|
-
.mockResolvedValueOnce(
|
|
165
|
-
createGuardedResult(
|
|
166
|
-
createMemexResponse("https://uploads.tlon.network/put"),
|
|
167
|
-
"https://memex.tlon.network/v1/zod/upload",
|
|
168
|
-
),
|
|
169
|
-
)
|
|
170
|
-
.mockRejectedValueOnce(new Error("Blocked upload target"));
|
|
171
|
-
|
|
172
|
-
await expect(
|
|
173
|
-
uploadFile({
|
|
174
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
175
|
-
fileName: "avatar.png",
|
|
176
|
-
contentType: "image/png",
|
|
177
|
-
}),
|
|
178
|
-
).rejects.toThrow("Blocked upload target");
|
|
179
|
-
|
|
180
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
181
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
|
|
182
|
-
const uploadCall = guardedFetchCall(1);
|
|
183
|
-
expect(uploadCall?.url).toBe("https://uploads.tlon.network/put");
|
|
184
|
-
expect(uploadCall?.auditContext).toBe("tlon-memex-upload");
|
|
185
|
-
expect(uploadCall?.capture).toBe(false);
|
|
186
|
-
expect(uploadCall?.maxRedirects).toBe(0);
|
|
187
|
-
expect(mockRelease).toHaveBeenCalledTimes(1);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("rejects Memex upload targets outside the hosted Tlon domain allowlist", async () => {
|
|
191
|
-
mockGuardedFetch.mockResolvedValueOnce(
|
|
192
|
-
createGuardedResult(
|
|
193
|
-
createMemexResponse("https://eviltlon.network/upload"),
|
|
194
|
-
"https://memex.tlon.network/v1/zod/upload",
|
|
195
|
-
),
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
await expect(
|
|
199
|
-
uploadFile({
|
|
200
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
201
|
-
fileName: "avatar.png",
|
|
202
|
-
contentType: "image/png",
|
|
203
|
-
}),
|
|
204
|
-
).rejects.toThrow("Memex upload URL must target a trusted hosted Tlon domain");
|
|
205
|
-
|
|
206
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
207
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
|
|
208
|
-
expect(mockRelease).toHaveBeenCalledTimes(1);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("rejects Memex hosted result URLs outside the hosted Tlon domain allowlist", async () => {
|
|
212
|
-
mockGuardedFetch
|
|
213
|
-
.mockResolvedValueOnce(
|
|
214
|
-
createGuardedResult(
|
|
215
|
-
createMemexResponse(
|
|
216
|
-
"https://uploads.tlon.network/put",
|
|
217
|
-
"https://evil.example/files/uploaded.png",
|
|
218
|
-
),
|
|
219
|
-
"https://memex.tlon.network/v1/zod/upload",
|
|
220
|
-
),
|
|
221
|
-
)
|
|
222
|
-
.mockResolvedValueOnce(
|
|
223
|
-
createGuardedResult(
|
|
224
|
-
new Response(null, { status: 200 }),
|
|
225
|
-
"https://uploads.tlon.network/put",
|
|
226
|
-
),
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
await expect(
|
|
230
|
-
uploadFile({
|
|
231
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
232
|
-
fileName: "avatar.png",
|
|
233
|
-
contentType: "image/png",
|
|
234
|
-
}),
|
|
235
|
-
).rejects.toThrow("Memex hosted URL must target a trusted hosted Tlon domain");
|
|
236
|
-
|
|
237
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
238
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
|
|
239
|
-
expect(mockRelease).toHaveBeenCalledTimes(2);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("rejects Memex upload targets with a non-standard port", async () => {
|
|
243
|
-
mockGuardedFetch.mockResolvedValueOnce(
|
|
244
|
-
createGuardedResult(
|
|
245
|
-
createMemexResponse("https://uploads.tlon.network:8443/put"),
|
|
246
|
-
"https://memex.tlon.network/v1/zod/upload",
|
|
247
|
-
),
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
await expect(
|
|
251
|
-
uploadFile({
|
|
252
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
253
|
-
fileName: "avatar.png",
|
|
254
|
-
contentType: "image/png",
|
|
255
|
-
}),
|
|
256
|
-
).rejects.toThrow("Memex upload URL must not specify a non-standard port");
|
|
257
|
-
|
|
258
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
259
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
|
|
260
|
-
expect(mockRelease).toHaveBeenCalledTimes(1);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("disables redirects for Memex upload targets", async () => {
|
|
264
|
-
mockGuardedFetch
|
|
265
|
-
.mockResolvedValueOnce(
|
|
266
|
-
createGuardedResult(
|
|
267
|
-
createMemexResponse("https://uploads.tlon.network/put"),
|
|
268
|
-
"https://memex.tlon.network/v1/zod/upload",
|
|
269
|
-
),
|
|
270
|
-
)
|
|
271
|
-
.mockRejectedValueOnce(new Error("Too many redirects (limit: 0)"));
|
|
272
|
-
|
|
273
|
-
await expect(
|
|
274
|
-
uploadFile({
|
|
275
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
276
|
-
fileName: "avatar.png",
|
|
277
|
-
contentType: "image/png",
|
|
278
|
-
}),
|
|
279
|
-
).rejects.toThrow("Too many redirects (limit: 0)");
|
|
280
|
-
|
|
281
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
282
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
|
|
283
|
-
const uploadCall = guardedFetchCall(1);
|
|
284
|
-
expect(uploadCall?.url).toBe("https://uploads.tlon.network/put");
|
|
285
|
-
expect(uploadCall?.auditContext).toBe("tlon-memex-upload");
|
|
286
|
-
expect(uploadCall?.capture).toBe(false);
|
|
287
|
-
expect(uploadCall?.maxRedirects).toBe(0);
|
|
288
|
-
expect(mockRelease).toHaveBeenCalledTimes(1);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it("routes scheme-less hosted ship URLs through the Memex upload path", async () => {
|
|
292
|
-
configureClient({
|
|
293
|
-
shipUrl: "foo.tlon.network",
|
|
294
|
-
shipName: "~zod",
|
|
295
|
-
verbose: false,
|
|
296
|
-
getCode: async () => "123456",
|
|
297
|
-
});
|
|
298
|
-
mockGuardedFetch
|
|
299
|
-
.mockResolvedValueOnce(
|
|
300
|
-
createGuardedResult(
|
|
301
|
-
createMemexResponse("https://uploads.tlon.network/put"),
|
|
302
|
-
"https://memex.tlon.network/v1/zod/upload",
|
|
303
|
-
),
|
|
304
|
-
)
|
|
305
|
-
.mockResolvedValueOnce(
|
|
306
|
-
createGuardedResult(
|
|
307
|
-
new Response(null, { status: 200 }),
|
|
308
|
-
"https://uploads.tlon.network/put",
|
|
309
|
-
),
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
const result = await uploadFile({
|
|
313
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
314
|
-
fileName: "avatar.png",
|
|
315
|
-
contentType: "image/png",
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
expect(result).toEqual({ url: "https://memex.tlon.network/files/uploaded.png" });
|
|
319
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
|
|
320
|
-
expect(mockRelease).toHaveBeenCalledTimes(2);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it("rejects truly unparseable ship URLs as not hosted", async () => {
|
|
324
|
-
configureClient({
|
|
325
|
-
shipUrl: " ",
|
|
326
|
-
shipName: "~zod",
|
|
327
|
-
verbose: false,
|
|
328
|
-
getCode: async () => "123456",
|
|
329
|
-
});
|
|
330
|
-
mockScryUrbitPath.mockImplementation(async (_deps, params) => {
|
|
331
|
-
if (params.path === "/storage/configuration.json") {
|
|
332
|
-
return {
|
|
333
|
-
currentBucket: "uploads",
|
|
334
|
-
buckets: ["uploads"],
|
|
335
|
-
publicUrlBase: "https://files.tlon.network/",
|
|
336
|
-
presignedUrl: "https://files.tlon.network/presigned",
|
|
337
|
-
region: "us-east-1",
|
|
338
|
-
service: "presigned-url",
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
if (params.path === "/storage/credentials.json") {
|
|
342
|
-
return { "storage-update": {} };
|
|
343
|
-
}
|
|
344
|
-
throw new Error(`Unexpected scry path: ${params.path}`);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
await expect(
|
|
348
|
-
uploadFile({
|
|
349
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
350
|
-
fileName: "avatar.png",
|
|
351
|
-
contentType: "image/png",
|
|
352
|
-
}),
|
|
353
|
-
).rejects.toThrow("No storage credentials configured");
|
|
354
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
355
|
-
expect(mockGuardedFetch).not.toHaveBeenCalled();
|
|
356
|
-
expect(mockRelease).not.toHaveBeenCalled();
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
it("accepts hosted Memex upload URLs with an explicit :443 port", async () => {
|
|
360
|
-
mockGuardedFetch
|
|
361
|
-
.mockResolvedValueOnce(
|
|
362
|
-
createGuardedResult(
|
|
363
|
-
createMemexResponse("https://uploads.tlon.network:443/put"),
|
|
364
|
-
"https://memex.tlon.network/v1/zod/upload",
|
|
365
|
-
),
|
|
366
|
-
)
|
|
367
|
-
.mockResolvedValueOnce(
|
|
368
|
-
createGuardedResult(
|
|
369
|
-
new Response(null, { status: 200 }),
|
|
370
|
-
"https://uploads.tlon.network:443/put",
|
|
371
|
-
),
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
const result = await uploadFile({
|
|
375
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
376
|
-
fileName: "avatar.png",
|
|
377
|
-
contentType: "image/png",
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
expect(result).toEqual({ url: "https://memex.tlon.network/files/uploaded.png" });
|
|
381
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(2);
|
|
382
|
-
expect(mockRelease).toHaveBeenCalledTimes(2);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("disables redirects for the Memex upload URL lookup", async () => {
|
|
386
|
-
mockGuardedFetch.mockRejectedValueOnce(new Error("Too many redirects (limit: 0)"));
|
|
387
|
-
|
|
388
|
-
await expect(
|
|
389
|
-
uploadFile({
|
|
390
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
391
|
-
fileName: "avatar.png",
|
|
392
|
-
contentType: "image/png",
|
|
393
|
-
}),
|
|
394
|
-
).rejects.toThrow("Too many redirects (limit: 0)");
|
|
395
|
-
|
|
396
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
397
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
|
|
398
|
-
const lookupCall = guardedFetchCall(0);
|
|
399
|
-
expect(lookupCall?.url).toBe("https://memex.tlon.network/v1/zod/upload");
|
|
400
|
-
expect(lookupCall?.auditContext).toBe("tlon-memex-upload-url");
|
|
401
|
-
expect(lookupCall?.capture).toBe(false);
|
|
402
|
-
expect(lookupCall?.maxRedirects).toBe(0);
|
|
403
|
-
expect(mockRelease).not.toHaveBeenCalled();
|
|
404
|
-
});
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
describe("uploadFile custom S3 upload hardening", () => {
|
|
408
|
-
beforeEach(() => {
|
|
409
|
-
vi.clearAllMocks();
|
|
410
|
-
vi.stubGlobal("fetch", vi.fn());
|
|
411
|
-
mockAuthenticate.mockResolvedValue("urbauth-~zod=fake-cookie");
|
|
412
|
-
configureClient({
|
|
413
|
-
shipUrl: "https://ship.example.com",
|
|
414
|
-
shipName: "~zod",
|
|
415
|
-
verbose: false,
|
|
416
|
-
getCode: async () => "123456",
|
|
417
|
-
});
|
|
418
|
-
mockScryUrbitPath.mockImplementation(async (_deps, params) => {
|
|
419
|
-
if (params.path === "/storage/configuration.json") {
|
|
420
|
-
return {
|
|
421
|
-
currentBucket: "uploads",
|
|
422
|
-
buckets: ["uploads"],
|
|
423
|
-
publicUrlBase: "https://files.example.com/",
|
|
424
|
-
presignedUrl: "",
|
|
425
|
-
region: "us-east-1",
|
|
426
|
-
service: "custom",
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
if (params.path === "/storage/credentials.json") {
|
|
430
|
-
return {
|
|
431
|
-
"storage-update": {
|
|
432
|
-
credentials: {
|
|
433
|
-
endpoint: "https://s3.example.com",
|
|
434
|
-
accessKeyId: "AKIAFAKE",
|
|
435
|
-
secretAccessKey: "fake-secret",
|
|
436
|
-
},
|
|
437
|
-
},
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
throw new Error(`Unexpected scry path: ${params.path}`);
|
|
441
|
-
});
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
afterEach(() => {
|
|
445
|
-
vi.unstubAllGlobals();
|
|
446
|
-
vi.restoreAllMocks();
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it("routes the custom S3 signed URL through the SSRF guard", async () => {
|
|
450
|
-
mockGetSignedUrl.mockResolvedValueOnce("https://s3.example.com/uploads/file?sig=abc");
|
|
451
|
-
mockGuardedFetch.mockResolvedValueOnce(
|
|
452
|
-
createGuardedResult(
|
|
453
|
-
new Response(null, { status: 200 }),
|
|
454
|
-
"https://s3.example.com/uploads/file?sig=abc",
|
|
455
|
-
),
|
|
456
|
-
);
|
|
457
|
-
|
|
458
|
-
const result = await uploadFile({
|
|
459
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
460
|
-
fileName: "avatar.png",
|
|
461
|
-
contentType: "image/png",
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
expect(result.url.startsWith("https://files.example.com/")).toBe(true);
|
|
465
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
|
|
466
|
-
const uploadCall = guardedFetchCall(0);
|
|
467
|
-
expect(uploadCall?.url).toBe("https://s3.example.com/uploads/file?sig=abc");
|
|
468
|
-
expect(uploadCall?.init?.method).toBe("PUT");
|
|
469
|
-
expect(uploadCall?.init?.headers).toBeUndefined();
|
|
470
|
-
expect(uploadCall?.auditContext).toBe("tlon-custom-s3-upload");
|
|
471
|
-
expect(uploadCall?.capture).toBe(false);
|
|
472
|
-
expect(uploadCall?.maxRedirects).toBe(0);
|
|
473
|
-
expect(uploadCall?.policy).toBeUndefined();
|
|
474
|
-
expect(mockRelease).toHaveBeenCalledTimes(1);
|
|
475
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
it("surfaces guarded upload failures for custom S3 targets without calling release", async () => {
|
|
479
|
-
mockGetSignedUrl.mockResolvedValueOnce("https://169.254.169.254/uploads/file?sig=abc");
|
|
480
|
-
mockGuardedFetch.mockRejectedValueOnce(new Error("Blocked private network target"));
|
|
481
|
-
|
|
482
|
-
await expect(
|
|
483
|
-
uploadFile({
|
|
484
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
485
|
-
fileName: "avatar.png",
|
|
486
|
-
contentType: "image/png",
|
|
487
|
-
}),
|
|
488
|
-
).rejects.toThrow("Blocked private network target");
|
|
489
|
-
|
|
490
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
|
|
491
|
-
expect(mockRelease).not.toHaveBeenCalled();
|
|
492
|
-
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it("passes the private-network opt-in to guarded custom S3 uploads", async () => {
|
|
496
|
-
configureClient({
|
|
497
|
-
shipUrl: "https://ship.example.com",
|
|
498
|
-
shipName: "~zod",
|
|
499
|
-
verbose: false,
|
|
500
|
-
getCode: async () => "123456",
|
|
501
|
-
dangerouslyAllowPrivateNetwork: true,
|
|
502
|
-
});
|
|
503
|
-
mockGetSignedUrl.mockResolvedValueOnce("https://10.0.0.15/uploads/file?sig=abc");
|
|
504
|
-
mockGuardedFetch.mockResolvedValueOnce(
|
|
505
|
-
createGuardedResult(
|
|
506
|
-
new Response(null, { status: 200 }),
|
|
507
|
-
"https://10.0.0.15/uploads/file?sig=abc",
|
|
508
|
-
),
|
|
509
|
-
);
|
|
510
|
-
|
|
511
|
-
const result = await uploadFile({
|
|
512
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
513
|
-
fileName: "avatar.png",
|
|
514
|
-
contentType: "image/png",
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
expect(result.url.startsWith("https://files.example.com/")).toBe(true);
|
|
518
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
|
|
519
|
-
const uploadCall = guardedFetchCall(0);
|
|
520
|
-
expect(uploadCall?.url).toBe("https://10.0.0.15/uploads/file?sig=abc");
|
|
521
|
-
expect(uploadCall?.auditContext).toBe("tlon-custom-s3-upload");
|
|
522
|
-
expect(uploadCall?.capture).toBe(false);
|
|
523
|
-
expect(uploadCall?.maxRedirects).toBe(0);
|
|
524
|
-
expect(uploadCall?.policy).toEqual({ allowPrivateNetwork: true });
|
|
525
|
-
expect(mockRelease).toHaveBeenCalledTimes(1);
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
it("rejects custom S3 result URLs that are not http(s)", async () => {
|
|
529
|
-
mockScryUrbitPath.mockImplementation(async (_deps, params) => {
|
|
530
|
-
if (params.path === "/storage/configuration.json") {
|
|
531
|
-
return {
|
|
532
|
-
currentBucket: "uploads",
|
|
533
|
-
buckets: ["uploads"],
|
|
534
|
-
publicUrlBase: "ftp://files.example.com/",
|
|
535
|
-
presignedUrl: "",
|
|
536
|
-
region: "us-east-1",
|
|
537
|
-
service: "custom",
|
|
538
|
-
};
|
|
539
|
-
}
|
|
540
|
-
if (params.path === "/storage/credentials.json") {
|
|
541
|
-
return {
|
|
542
|
-
"storage-update": {
|
|
543
|
-
credentials: {
|
|
544
|
-
endpoint: "https://s3.example.com",
|
|
545
|
-
accessKeyId: "AKIAFAKE",
|
|
546
|
-
secretAccessKey: "fake-secret",
|
|
547
|
-
},
|
|
548
|
-
},
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
throw new Error(`Unexpected scry path: ${params.path}`);
|
|
552
|
-
});
|
|
553
|
-
mockGetSignedUrl.mockResolvedValueOnce("https://s3.example.com/uploads/file?sig=abc");
|
|
554
|
-
mockGuardedFetch.mockResolvedValueOnce(
|
|
555
|
-
createGuardedResult(
|
|
556
|
-
new Response(null, { status: 200 }),
|
|
557
|
-
"https://s3.example.com/uploads/file?sig=abc",
|
|
558
|
-
),
|
|
559
|
-
);
|
|
560
|
-
|
|
561
|
-
await expect(
|
|
562
|
-
uploadFile({
|
|
563
|
-
blob: new Blob(["image-bytes"], { type: "image/png" }),
|
|
564
|
-
fileName: "avatar.png",
|
|
565
|
-
contentType: "image/png",
|
|
566
|
-
}),
|
|
567
|
-
).rejects.toThrow("Upload result URL must use http or https");
|
|
568
|
-
|
|
569
|
-
expect(mockGuardedFetch).toHaveBeenCalledTimes(1);
|
|
570
|
-
expect(mockRelease).toHaveBeenCalledTimes(1);
|
|
571
|
-
});
|
|
572
|
-
});
|