Skip to content

Commit 3150bfa

Browse files
authored
fix: data uri write prevention (#1372)
fix: Add read-only data URI write prevention
1 parent 6f15e15 commit 3150bfa

File tree

4 files changed

+227
-5
lines changed

4 files changed

+227
-5
lines changed

packages/runner/src/storage/interface.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,13 +481,15 @@ export type ReadError =
481481
export type WriteError =
482482
| INotFoundError
483483
| IUnsupportedMediaTypeError
484-
| InactiveTransactionError;
484+
| InactiveTransactionError
485+
| IReadOnlyAddressError;
485486

486487
export type ReaderError = InactiveTransactionError;
487488

488489
export type WriterError =
489490
| InactiveTransactionError
490-
| IStorageTransactionWriteIsolationError;
491+
| IStorageTransactionWriteIsolationError
492+
| IReadOnlyAddressError;
491493

492494
export interface IStorageTransactionComplete extends Error {
493495
name: "StorageTransactionCompleteError";
@@ -658,6 +660,20 @@ export interface IStorageTransactionWriteIsolationError extends Error {
658660
requested: MemorySpace;
659661
}
660662

663+
/**
664+
* Error returned when attempting to write to a read-only address (data: URI).
665+
*/
666+
export interface IReadOnlyAddressError extends Error {
667+
name: "ReadOnlyAddressError";
668+
669+
/**
670+
* The read-only address that was attempted to be written to.
671+
*/
672+
address: IMemoryAddress;
673+
674+
from(space: MemorySpace): IReadOnlyAddressError;
675+
}
676+
661677
/**
662678
* Describes either observed or desired state of the memory at a specific
663679
* address.

packages/runner/src/storage/transaction/chronicle.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
IAttestation,
33
IInvalidDataURIError,
44
IMemoryAddress,
5+
IReadOnlyAddressError,
56
ISpaceReplica,
67
IStorageTransactionInconsistent,
78
ITransaction,
@@ -30,6 +31,23 @@ export const open = (replica: ISpaceReplica) => new Chronicle(replica);
3031

3132
export { InvalidDataURIError, UnsupportedMediaTypeError };
3233

34+
export class ReadOnlyAddressError extends Error
35+
implements IReadOnlyAddressError {
36+
override readonly name = "ReadOnlyAddressError";
37+
declare readonly address: IMemoryAddress;
38+
39+
constructor(address: IMemoryAddress) {
40+
super(
41+
`Cannot write to read-only address: ${address.id}`,
42+
);
43+
this.address = address;
44+
}
45+
46+
from(space: MemorySpace) {
47+
return this;
48+
}
49+
}
50+
3351
export class Chronicle {
3452
#replica: ISpaceReplica;
3553
#history: History;
@@ -76,7 +94,15 @@ export class Chronicle {
7694
write(
7795
address: IMemoryAddress,
7896
value?: JSONValue,
79-
): Result<IAttestation, IStorageTransactionInconsistent> {
97+
): Result<
98+
IAttestation,
99+
IStorageTransactionInconsistent | ReadOnlyAddressError
100+
> {
101+
// Check if address is inline (data: URI) - these are read-only
102+
if (Address.isInline(address)) {
103+
return { error: new ReadOnlyAddressError(address) };
104+
}
105+
80106
// Validate against current state (replica + any overlapping novelty)
81107
const loaded = attest(this.load(address));
82108
const rebase = this.rebase(loaded);

packages/runner/test/chronicle.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,6 +1229,182 @@ describe("Chronicle", () => {
12291229
expect(result.ok!.value).toEqual({ hello: "world" });
12301230
});
12311231

1232+
it("should fail to write to data URI at root level", () => {
1233+
const address = {
1234+
id: 'data:application/json,{"hello":"world"}' as const,
1235+
type: "application/json" as const,
1236+
path: [],
1237+
};
1238+
1239+
const chronicle = Chronicle.open(replica);
1240+
const result = chronicle.write(address, { hello: "updated" });
1241+
1242+
expect(result.error).toBeDefined();
1243+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1244+
expect(result.error!.message).toContain(
1245+
"Cannot write to read-only address",
1246+
);
1247+
expect(result.error!.message).toContain(address.id);
1248+
expect(result.error!.address).toEqual(address);
1249+
});
1250+
1251+
it("should fail to write to nested path in data URI", () => {
1252+
const address = {
1253+
id: 'data:application/json,{"user":{"name":"Alice"}}' as const,
1254+
type: "application/json" as const,
1255+
path: ["user", "name"],
1256+
};
1257+
1258+
const chronicle = Chronicle.open(replica);
1259+
const result = chronicle.write(address, "Bob");
1260+
1261+
expect(result.error).toBeDefined();
1262+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1263+
expect(result.error!.address).toEqual(address);
1264+
});
1265+
1266+
it("should fail to write undefined (delete) to data URI", () => {
1267+
const address = {
1268+
id: 'data:application/json,{"hello":"world"}' as const,
1269+
type: "application/json" as const,
1270+
path: ["hello"],
1271+
};
1272+
1273+
const chronicle = Chronicle.open(replica);
1274+
const result = chronicle.write(address, undefined);
1275+
1276+
expect(result.error).toBeDefined();
1277+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1278+
});
1279+
1280+
it("should fail to write to base64 encoded data URI", () => {
1281+
const address = {
1282+
id: "data:application/json;base64,eyJoZWxsbyI6IndvcmxkIn0=" as const,
1283+
type: "application/json" as const,
1284+
path: [],
1285+
};
1286+
1287+
const chronicle = Chronicle.open(replica);
1288+
const result = chronicle.write(address, { hello: "updated" });
1289+
1290+
expect(result.error).toBeDefined();
1291+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1292+
});
1293+
1294+
it("should fail to write to text/plain data URI", () => {
1295+
const address = {
1296+
id: "data:text/plain,hello%20world" as const,
1297+
type: "text/plain" as const,
1298+
path: [],
1299+
};
1300+
1301+
const chronicle = Chronicle.open(replica);
1302+
const result = chronicle.write(address, "goodbye world");
1303+
1304+
expect(result.error).toBeDefined();
1305+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1306+
});
1307+
1308+
it("should allow writes to regular addresses after failing to write to data URI", () => {
1309+
const dataUriAddress = {
1310+
id: 'data:application/json,{"hello":"world"}' as const,
1311+
type: "application/json" as const,
1312+
path: [],
1313+
};
1314+
1315+
const regularAddress = {
1316+
id: "test:regular",
1317+
type: "application/json",
1318+
path: [],
1319+
} as const;
1320+
1321+
const chronicle = Chronicle.open(replica);
1322+
1323+
// First, fail to write to data URI
1324+
const dataUriResult = chronicle.write(dataUriAddress, {
1325+
hello: "updated",
1326+
});
1327+
expect(dataUriResult.error).toBeDefined();
1328+
expect(dataUriResult.error!.name).toBe("ReadOnlyAddressError");
1329+
1330+
// Then, successfully write to regular address
1331+
const regularResult = chronicle.write(regularAddress, {
1332+
status: "active",
1333+
});
1334+
expect(regularResult.ok).toBeDefined();
1335+
expect(regularResult.ok!.value).toEqual({ status: "active" });
1336+
});
1337+
1338+
it("should check for data URI before any other validation", () => {
1339+
// This tests that we check for data URI early, before loading or rebasing
1340+
const address = {
1341+
id: 'data:application/json,{"complex":{"nested":"value"}}' as const,
1342+
type: "application/json" as const,
1343+
path: ["complex", "nested"],
1344+
};
1345+
1346+
const chronicle = Chronicle.open(replica);
1347+
const result = chronicle.write(address, "new value");
1348+
1349+
// Should fail immediately with ReadOnlyAddressError, not with any other error
1350+
expect(result.error).toBeDefined();
1351+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1352+
});
1353+
1354+
it("should handle data URI with special characters in write attempt", () => {
1355+
const address = {
1356+
id:
1357+
"data:application/json,%7B%22key%22%3A%22value%20with%20spaces%22%7D" as const,
1358+
type: "application/json" as const,
1359+
path: [],
1360+
};
1361+
1362+
const chronicle = Chronicle.open(replica);
1363+
const result = chronicle.write(address, { key: "new value" });
1364+
1365+
expect(result.error).toBeDefined();
1366+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1367+
});
1368+
1369+
it("should fail to write to array element in data URI", () => {
1370+
const address = {
1371+
id: 'data:application/json,["a","b","c"]' as const,
1372+
type: "application/json" as const,
1373+
path: ["1"],
1374+
};
1375+
1376+
const chronicle = Chronicle.open(replica);
1377+
const result = chronicle.write(address, "B");
1378+
1379+
expect(result.error).toBeDefined();
1380+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1381+
});
1382+
1383+
it("should provide helpful error message with the data URI", () => {
1384+
const longDataUri = `data:application/json,${
1385+
encodeURIComponent(JSON.stringify({
1386+
users: Array(10).fill(null).map((_, i) => ({
1387+
id: i,
1388+
name: `User ${i}`,
1389+
email: `user${i}@example.com`,
1390+
})),
1391+
}))
1392+
}`;
1393+
1394+
const address = {
1395+
id: longDataUri as `data:${string}`,
1396+
type: "application/json" as const,
1397+
path: ["users", "0", "name"] as const,
1398+
};
1399+
1400+
const chronicle = Chronicle.open(replica);
1401+
const result = chronicle.write(address, "Updated Name");
1402+
1403+
expect(result.error).toBeDefined();
1404+
expect(result.error!.name).toBe("ReadOnlyAddressError");
1405+
expect(result.error!.message).toContain(longDataUri);
1406+
});
1407+
12321408
it("should read base64 encoded JSON data URI", () => {
12331409
const address = {
12341410
id: "data:application/json;base64,eyJoZWxsbyI6IndvcmxkIn0=" as const,

packages/runner/test/journal.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,9 @@ describe("Journal", () => {
356356

357357
const newReaderResult = journal.reader(space);
358358
expect(newReaderResult.error).toBeDefined();
359-
expect(newReaderResult.error?.name).toBe("StorageTransactionCompleteError");
359+
expect(newReaderResult.error?.name).toBe(
360+
"StorageTransactionCompleteError",
361+
);
360362

361363
const readResult = reader!.read({
362364
id: "test:closed",
@@ -394,7 +396,9 @@ describe("Journal", () => {
394396

395397
const newWriterResult = journal.writer(space);
396398
expect(newWriterResult.error).toBeDefined();
397-
expect(newWriterResult.error?.name).toBe("StorageTransactionCompleteError");
399+
expect(newWriterResult.error?.name).toBe(
400+
"StorageTransactionCompleteError",
401+
);
398402

399403
const readResult = writer!.read({
400404
id: "test:closed-write",

0 commit comments

Comments
 (0)