Skip to content
Open
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
23 changes: 23 additions & 0 deletions .changeset/add-content-uri-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@hypercerts-org/sdk-core": minor
---

**BREAKING CHANGE (pre-1.0):** Add strict URI validation for attachment content strings

This introduces a breaking behavioral change for 0.x consumers:

- Add `isValidUri` utility to validate URI strings have a proper scheme (supports http, https, at://, ipfs://, and any
RFC 3986-compliant scheme)
- **`addAttachment` now throws `ValidationError`** when content strings are not valid URIs (e.g., plain text like
`"not-a-uri"`)
- Export `isValidUri` from the public API for consumer use

**Migration Guide:** Existing code that passes plain text or non-URI strings to `addAttachment` will now fail with a
`ValidationError`. To migrate:

1. Ensure all content strings passed to `addAttachment` are valid URIs
2. Use the new `isValidUri` utility to validate strings before passing them
3. Convert plain text content to proper URI format (e.g., data URIs, IPFS URIs, or HTTP URLs)

**Compatibility Note:** Consumers relying on the previous lenient behavior that accepted non-URI strings must update
their code. The validation now strictly enforces that attachment content must be a valid URI with a recognized scheme.
3 changes: 3 additions & 0 deletions packages/sdk-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ export type {
Permission,
} from "./auth/permissions.js";

// URL Utilities
export { isValidUri } from "./lib/url-utils.js";

// Rich Text Utilities
export { createFacetsFromText, createFacetsFromTextSync, RichText } from "./lib/rich-text.js";
export type { RichTextResult, AppBskyRichtextFacet } from "./lib/rich-text.js";
43 changes: 43 additions & 0 deletions packages/sdk-core/src/lib/url-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
/**
* Regular expression to match a valid URI with a scheme.
*
* Matches strings that start with a scheme (one or more alphanumeric characters,
* plus, period, or hyphen) followed by a colon. This covers schemes that the
* native URL constructor may not support (e.g., `at://`, `ipfs://`).
*
* @see https://www.rfc-editor.org/rfc/rfc3986#section-3.1
* @internal
*/
const URI_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/;

/**
* Check if a string is a valid URI with a scheme.
*
* Validates that the string is a properly formatted URI. Uses the native
* `URL` constructor for standard schemes (http, https, ftp, etc.) and falls
* back to scheme detection for non-standard schemes like `at://` and `ipfs://`
* that the `URL` constructor does not support.
*
* @param uri - The string to validate
* @returns True if the string is a valid URI with a scheme, false otherwise
*
* @example
* ```typescript
* isValidUri("https://example.com/report.pdf"); // true
* isValidUri("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); // true
* isValidUri("at://did:plc:abc/org.col/rkey"); // true
* isValidUri("not-a-uri"); // false
* isValidUri(""); // false
* ```
*/
export function isValidUri(uri: string): boolean {
if (!uri) return false;

try {
new URL(uri);
return true;
} catch {
return URI_SCHEME_REGEX.test(uri);
}
}

/**
* Type guard to check if a URL is a loopback address.
*
Expand Down
11 changes: 11 additions & 0 deletions packages/sdk-core/src/repository/HypercertOperationsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import type { CreateResult, ListParams, PaginatedList, ProgressStep, UpdateResul
import { uploadResultToBlobRef } from "./types.js";
import { $Typed } from "@atproto/api";
import { sha256Hash } from "../lib/crypto.js";
import { isValidUri } from "../lib/url-utils.js";

/**
* Implementation of high-level hypercert operations.
Expand Down Expand Up @@ -1109,12 +1110,22 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
*
* @param contentInput - Single content item or array (URI strings or Blobs)
* @returns Promise resolving to array of URI refs or Blob refs
* @throws {@link ValidationError} if a string content item is not a valid URI
* @throws {@link NetworkError} if blob upload fails
* @internal
*/
private async resolveAttachmentContent(contentInput: string | Blob | Array<string | Blob>) {
const contentArray = Array.isArray(contentInput) ? contentInput : [contentInput];

// Validate that all string content items are valid URIs before resolving
for (const item of contentArray) {
if (typeof item === "string" && !isValidUri(item)) {
throw new ValidationError(
`Invalid URI: "${item}". Content must be a valid URI with a scheme (e.g., https://example.com)`,
);
}
}

return await Promise.all(contentArray.map((item) => this.resolveUriOrBlob(item, "application/octet-stream")));
}

Expand Down
69 changes: 69 additions & 0 deletions packages/sdk-core/tests/lib/url-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { isValidUri } from "../../src/lib/url-utils.js";

describe("isValidUri", () => {
describe("valid URIs", () => {
it("should accept https URLs", () => {
expect(isValidUri("https://example.com")).toBe(true);
expect(isValidUri("https://example.com/path/to/resource")).toBe(true);
expect(isValidUri("https://example.com/report.pdf")).toBe(true);
expect(isValidUri("https://example.com:8080/path?q=1&r=2#frag")).toBe(true);
});

it("should accept http URLs", () => {
expect(isValidUri("http://example.com")).toBe(true);
expect(isValidUri("http://localhost:3000")).toBe(true);
});

it("should accept AT Protocol URIs", () => {
expect(isValidUri("at://did:plc:abc123/org.hypercerts.claim.activity/rkey")).toBe(true);
expect(isValidUri("at://did:plc:test/org.hypercerts.claim.record/def456")).toBe(true);
expect(isValidUri("at://did:web:example.com/app.bsky.feed.post/3km2vj4kfqp2a")).toBe(true);
});

it("should accept ftp URIs", () => {
expect(isValidUri("ftp://files.example.com/doc.txt")).toBe(true);
});

it("should accept ipfs URIs", () => {
expect(isValidUri("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG")).toBe(true);
expect(isValidUri("ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi")).toBe(true);
});

it("should accept data URIs", () => {
expect(isValidUri("data:text/plain;base64,SGVsbG8=")).toBe(true);
});

it("should accept mailto URIs", () => {
expect(isValidUri("mailto:user@example.com")).toBe(true);
});
});

describe("invalid URIs", () => {
it("should reject plain text", () => {
expect(isValidUri("not-a-uri")).toBe(false);
expect(isValidUri("just some text")).toBe(false);
expect(isValidUri("hello world")).toBe(false);
});

it("should reject empty strings", () => {
expect(isValidUri("")).toBe(false);
});

it("should reject bare hostnames without scheme", () => {
expect(isValidUri("example.com")).toBe(false);
expect(isValidUri("www.example.com")).toBe(false);
});

it("should reject relative paths", () => {
expect(isValidUri("/relative/path")).toBe(false);
expect(isValidUri("./relative/path")).toBe(false);
expect(isValidUri("../parent/path")).toBe(false);
});

it("should reject strings with only a colon", () => {
expect(isValidUri(":not-valid")).toBe(false);
expect(isValidUri("://missing-scheme")).toBe(false);
});
});
});
47 changes: 47 additions & 0 deletions packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1464,6 +1464,53 @@ describe("HypercertOperationsImpl", () => {
}),
).rejects.toThrow(NetworkError);
});

it("should throw ValidationError when content URI is not a valid URI", async () => {
await expect(
hypercertOps.addAttachment({
subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc",
title: "Attachment with invalid URI",
content: "not-a-valid-uri",
}),
).rejects.toThrow(ValidationError);
});

it("should throw ValidationError when content URI is plain text", async () => {
await expect(
hypercertOps.addAttachment({
subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc",
title: "Attachment with plain text",
content: "just some random text",
}),
).rejects.toThrow(ValidationError);
});

it("should throw ValidationError when any content URI in array is invalid", async () => {
await expect(
hypercertOps.addAttachment({
subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc",
title: "Attachment with mixed content",
content: ["https://example.com/valid.pdf", "not-a-valid-uri"],
}),
).rejects.toThrow(ValidationError);
});

it("should accept valid non-http URI schemes in content", async () => {
const result = await hypercertOps.addAttachment({
subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc",
title: "IPFS Attachment",
content: "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
});

expect(result.uri).toContain("attachment");
const call = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0];
expect(call.record.content).toEqual([
{
$type: "org.hypercerts.defs#uri",
uri: "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
},
]);
});
});

describe("addMeasurement", () => {
Expand Down