8000 fix: data uri write prevention by Gozala · Pull Request #1372 · commontoolsinc/labs · GitHub
Skip to content
Merged
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: 18 additions & 2 deletions packages/runner/src/storage/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,13 +481,15 @@ export type ReadError =
export type WriteError =
| INotFoundError
| IUnsupportedMediaTypeError
| InactiveTransactionError;
| InactiveTransactionError
| IReadOnlyAddressError;

export type ReaderError = InactiveTransactionError;

export type WriterError =
| InactiveTransactionError
| IStorageTransactionWriteIsolationError;
| IStorageTransactionWriteIsolationError
| IReadOnlyAddressError;

export interface IStorageTransactionComplete extends Error {
name: "StorageTransactionCompleteError";
Expand Down Expand Up @@ -658,6 +660,20 @@ export interface IStorageTransactionWriteIsolationError extends Error {
requested: MemorySpace;
}

/**
* Error returned when attempting to write to a read-only address (data: URI).
*/
export interface IReadOnlyAddressError extends Error {
name: "ReadOnlyAddressError";

/**
* The read-only address that was attempted to be written to.
*/
address: IMemoryAddress;

from(space: MemorySpace): IReadOnlyAddressError;
}

/**
* Describes either observed or desired state of the memory at a specific
* address.
Expand Down
28 changes: 27 additions & 1 deletion packages/runner/src/storage/transaction/chronicle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
IAttestation,
IInvalidDataURIError,
IMemoryAddress,
IReadOnlyAddressError,
ISpaceReplica,
IStorageTransactionInconsistent,
ITransaction,
Expand Down Expand Up @@ -30,6 +31,23 @@ export const open = (replica: ISpaceReplica) => new Chronicle(replica);

export { InvalidDataURIError, UnsupportedMediaTypeError };

export class ReadOnlyAddressError extends Error
implements IReadOnlyAddressError {
override readonly name = "ReadOnlyAddressError";
declare readonly address: IMemoryAddress;

constructor(address: IMemoryAddress) {
super(
`Cannot write to read-only address: ${address.id}`,
);
this.address = address;
}

from(space: MemorySpace) {
return this;
}
}

export class Chronicle {
#replica: ISpaceReplica;
#history: History;
Expand Down Expand Up @@ -76,7 +94,15 @@ export class Chronicle {
write(
address: IMemoryAddress,
value?: JSONValue,
): Result<IAttestation, IStorageTransactionInconsistent> {
): Result<
IAttestation,
IStorageTransactionInconsistent | ReadOnlyAddressError
> {
// Check if address is inline (data: URI) - these are read-only
if (Address.isInline(address)) {
return { error: new ReadOnlyAddressError(address) };
}

// Validate against current state (replica + any overlapping novelty)
const loaded = attest(this.load(address));
const rebase = this.rebase(loaded);
Expand Down
176 changes: 176 additions & 0 deletions packages/runner/test/chronicle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,182 @@ describe("Chronicle", () => {
expect(result.ok!.value).toEqual({ hello: "world" });
});

it("should fail to write to data URI at root level", () => {
const address = {
id: 'data:application/json,{"hello":"world"}' as const,
type: "application/json" as const,
path: [],
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, { hello: "updated" });

expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
expect(result.error!.message).toContain(
"Cannot write to read-only address",
);
expect(result.error!.message).toContain(address.id);
expect(result.error!.address).toEqual(address);
});

it("should fail to write to nested path in data URI", () => {
const address = {
id: 'data:application/json,{"user":{"name":"Alice"}}' as const,
type: "application/json" as const,
path: ["user", "name"],
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, "Bob");

expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
expect(result.error!.address).toEqual(address);
});

it("should fail to write undefined (delete) to data URI", () => {
const address = {
id: 'data:application/json,{"hello":"world"}' as const,
type: "application/json" as const,
path: ["hello"],
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, undefined);

expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
});

it("should fail to write to base64 encoded data URI", () => {
const address = {
id: "data:application/json;base64,eyJoZWxsbyI6IndvcmxkIn0=" as const,
type: "application/json" as const,
path: [],
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, { hello: "updated" });

expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
});

it("should fail to write to text/plain data URI", () => {
const address = {
id: "data:text/plain,hello%20world" as const,
type: "text/plain" as const,
path: [],
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, "goodbye world");

expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
});

it("should allow writes to regular addresses after failing to write to data URI", () => {
const dataUriAddress = {
id: 'data:application/json,{"hello":"world"}' as const,
type: "application/json" as const,
path: [],
};

const regularAddress = {
id: "test:regular",
type: "application/json",
path: [],
} as const;

const chronicle = Chronicle.open(replica);

// First, fail to write to data URI
const dataUriResult = chronicle.write(dataUriAddress, {
hello: "updated",
});
expect(dataUriResult.error).toBeDefined();
expect(dataUriResult.error!.name).toBe("ReadOnlyAddressError");

// Then, successfully write to regular address
const regularResult = chronicle.write(regularAddress, {
status: "active",
});
expect(regularResult.ok).toBeDefined();
expect(regularResult.ok!.value).toEqual({ status: "active" });
});

it("should check for data URI before any other validation", () => {
// This tests that we check for data URI early, before loading or rebasing
const address = {
id: 'data:application/json,{"complex":{"nested":"value"}}' as const,
type: "application/json" as const,
path: ["complex", "nested"],
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, "new value");

// Should fail immediately with ReadOnlyAddressError, not with any other error
expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
});

it("should handle data URI with special characters in write attempt", () => {
const address = {
id:
"data:application/json,%7B%22key%22%3A%22value%20with%20spaces%22%7D" as const,
type: "application/json" as const,
path: [],
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, { key: "new value" });

expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
});

it("should fail to write to array element in data URI", () => {
const address = {
id: 'data:application/json,["a","b","c"]' as const,
type: "application/json" as const,
path: ["1"],
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, "B");

expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
});

it("should provide helpful error message with the data URI", () => {
const longDataUri = `data:application/json,${
encodeURIComponent(JSON.stringify({
users: Array(10).fill(null).map((_, i) => ({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
})),
}))
}`;

const address = {
id: longDataUri as `data:${string}`,
type: "application/json" as const,
path: ["users", "0", "name"] as const,
};

const chronicle = Chronicle.open(replica);
const result = chronicle.write(address, "Updated Name");

expect(result.error).toBeDefined();
expect(result.error!.name).toBe("ReadOnlyAddressError");
expect(result.error!.message).toContain(longDataUri);
});

it("should read base64 encoded JSON data URI", () => {
const address = {
id: "data:application/json;base64,eyJoZWxsbyI6IndvcmxkIn0=" as const,
Expand Down
8 changes: 6 additions & 2 deletions packages/runner/test/journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ describe("Journal", () => {

const newReaderResult = journal.reader(space);
expect(newReaderResult.error).toBeDefined();
expect(newReaderResult.error?.name).toBe("StorageTransactionCompleteError");
expect(newReaderResult.error?.name).toBe(
"StorageTransactionCompleteError",
);

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

const newWriterResult = journal.writer(space);
expect(newWriterResult.error).toBeDefined();
expect(newWriterResult.error?.name).toBe("StorageTransactionCompleteError");
expect(newWriterResult.error?.name).toBe(
"StorageTransactionCompleteError",
);

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