Skip to content

Commit 248d609

Browse files
authored
Merge pull request #128 from Kzoeps/97-add-integrity-check-for-evidence-content-string-being-a-uri
feat(sdk-core): add URI validation for attachment content strings
2 parents 2922750 + d10642f commit 248d609

6 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@hypercerts-org/sdk-core": minor
3+
---
4+
5+
**BREAKING CHANGE (pre-1.0):** Add strict URI validation for attachment content strings
6+
7+
This introduces a breaking behavioral change for 0.x consumers:
8+
9+
- Add `isValidUri` utility to validate URI strings have a proper scheme (supports http, https, at://, ipfs://, and any
10+
RFC 3986-compliant scheme)
11+
- **`addAttachment` now throws `ValidationError`** when content strings are not valid URIs (e.g., plain text like
12+
`"not-a-uri"`)
13+
- Export `isValidUri` from the public API for consumer use
14+
15+
**Migration Guide:** Existing code that passes plain text or non-URI strings to `addAttachment` will now fail with a
16+
`ValidationError`. To migrate:
17+
18+
1. Ensure all content strings passed to `addAttachment` are valid URIs
19+
2. Use the new `isValidUri` utility to validate strings before passing them
20+
3. Convert plain text content to proper URI format (e.g., data URIs, IPFS URIs, or HTTP URLs)
21+
22+
**Compatibility Note:** Consumers relying on the previous lenient behavior that accepted non-URI strings must update
23+
their code. The validation now strictly enforces that attachment content must be a valid URI with a recognized scheme.

packages/sdk-core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@ export type {
250250
Permission,
251251
} from "./auth/permissions.js";
252252

253+
// URL Utilities
254+
export { isValidUri } from "./lib/url-utils.js";
255+
253256
// Rich Text Utilities
254257
export { createFacetsFromText, createFacetsFromTextSync, RichText } from "./lib/rich-text.js";
255258
export type { RichTextResult, AppBskyRichtextFacet } from "./lib/rich-text.js";

packages/sdk-core/src/lib/url-utils.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,46 @@
1+
/**
2+
* Regular expression to match a valid URI with a scheme.
3+
*
4+
* Matches strings that start with a scheme (one or more alphanumeric characters,
5+
* plus, period, or hyphen) followed by a colon. This covers schemes that the
6+
* native URL constructor may not support (e.g., `at://`, `ipfs://`).
7+
*
8+
* @see https://www.rfc-editor.org/rfc/rfc3986#section-3.1
9+
* @internal
10+
*/
11+
const URI_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/;
12+
13+
/**
14+
* Check if a string is a valid URI with a scheme.
15+
*
16+
* Validates that the string is a properly formatted URI. Uses the native
17+
* `URL` constructor for standard schemes (http, https, ftp, etc.) and falls
18+
* back to scheme detection for non-standard schemes like `at://` and `ipfs://`
19+
* that the `URL` constructor does not support.
20+
*
21+
* @param uri - The string to validate
22+
* @returns True if the string is a valid URI with a scheme, false otherwise
23+
*
24+
* @example
25+
* ```typescript
26+
* isValidUri("https://example.com/report.pdf"); // true
27+
* isValidUri("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); // true
28+
* isValidUri("at://did:plc:abc/org.col/rkey"); // true
29+
* isValidUri("not-a-uri"); // false
30+
* isValidUri(""); // false
31+
* ```
32+
*/
33+
export function isValidUri(uri: string): boolean {
34+
if (!uri) return false;
35+
36+
try {
37+
new URL(uri);
38+
return true;
39+
} catch {
40+
return URI_SCHEME_REGEX.test(uri);
41+
}
42+
}
43+
144
/**
245
* Type guard to check if a URL is a loopback address.
346
*

packages/sdk-core/src/repository/HypercertOperationsImpl.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import type { CreateResult, ListParams, PaginatedList, ProgressStep, UpdateResul
5454
import { uploadResultToBlobRef } from "./types.js";
5555
import { $Typed } from "@atproto/api";
5656
import { sha256Hash } from "../lib/crypto.js";
57+
import { isValidUri } from "../lib/url-utils.js";
5758

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

1120+
// Validate that all string content items are valid URIs before resolving
1121+
for (const item of contentArray) {
1122+
if (typeof item === "string" && !isValidUri(item)) {
1123+
throw new ValidationError(
1124+
`Invalid URI: "${item}". Content must be a valid URI with a scheme (e.g., https://example.com)`,
1125+
);
1126+
}
1127+
}
1128+
11181129
return await Promise.all(contentArray.map((item) => this.resolveUriOrBlob(item, "application/octet-stream")));
11191130
}
11201131

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect } from "vitest";
2+
import { isValidUri } from "../../src/lib/url-utils.js";
3+
4+
describe("isValidUri", () => {
5+
describe("valid URIs", () => {
6+
it("should accept https URLs", () => {
7+
expect(isValidUri("https://example.com")).toBe(true);
8+
expect(isValidUri("https://example.com/path/to/resource")).toBe(true);
9+
expect(isValidUri("https://example.com/report.pdf")).toBe(true);
10+
expect(isValidUri("https://example.com:8080/path?q=1&r=2#frag")).toBe(true);
11+
});
12+
13+
it("should accept http URLs", () => {
14+
expect(isValidUri("http://example.com")).toBe(true);
15+
expect(isValidUri("http://localhost:3000")).toBe(true);
16+
});
17+
18+
it("should accept AT Protocol URIs", () => {
19+
expect(isValidUri("at://did:plc:abc123/org.hypercerts.claim.activity/rkey")).toBe(true);
20+
expect(isValidUri("at://did:plc:test/org.hypercerts.claim.record/def456")).toBe(true);
21+
expect(isValidUri("at://did:web:example.com/app.bsky.feed.post/3km2vj4kfqp2a")).toBe(true);
22+
});
23+
24+
it("should accept ftp URIs", () => {
25+
expect(isValidUri("ftp://files.example.com/doc.txt")).toBe(true);
26+
});
27+
28+
it("should accept ipfs URIs", () => {
29+
expect(isValidUri("ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG")).toBe(true);
30+
expect(isValidUri("ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi")).toBe(true);
31+
});
32+
33+
it("should accept data URIs", () => {
34+
expect(isValidUri("data:text/plain;base64,SGVsbG8=")).toBe(true);
35+
});
36+
37+
it("should accept mailto URIs", () => {
38+
expect(isValidUri("mailto:user@example.com")).toBe(true);
39+
});
40+
});
41+
42+
describe("invalid URIs", () => {
43+
it("should reject plain text", () => {
44+
expect(isValidUri("not-a-uri")).toBe(false);
45+
expect(isValidUri("just some text")).toBe(false);
46+
expect(isValidUri("hello world")).toBe(false);
47+
});
48+
49+
it("should reject empty strings", () => {
50+
expect(isValidUri("")).toBe(false);
51+
});
52+
53+
it("should reject bare hostnames without scheme", () => {
54+
expect(isValidUri("example.com")).toBe(false);
55+
expect(isValidUri("www.example.com")).toBe(false);
56+
});
57+
58+
it("should reject relative paths", () => {
59+
expect(isValidUri("/relative/path")).toBe(false);
60+
expect(isValidUri("./relative/path")).toBe(false);
61+
expect(isValidUri("../parent/path")).toBe(false);
62+
});
63+
64+
it("should reject strings with only a colon", () => {
65+
expect(isValidUri(":not-valid")).toBe(false);
66+
expect(isValidUri("://missing-scheme")).toBe(false);
67+
});
68+
});
69+
});

packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,53 @@ describe("HypercertOperationsImpl", () => {
14641464
}),
14651465
).rejects.toThrow(NetworkError);
14661466
});
1467+
1468+
it("should throw ValidationError when content URI is not a valid URI", async () => {
1469+
await expect(
1470+
hypercertOps.addAttachment({
1471+
subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc",
1472+
title: "Attachment with invalid URI",
1473+
content: "not-a-valid-uri",
1474+
}),
1475+
).rejects.toThrow(ValidationError);
1476+
});
1477+
1478+
it("should throw ValidationError when content URI is plain text", async () => {
1479+
await expect(
1480+
hypercertOps.addAttachment({
1481+
subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc",
1482+
title: "Attachment with plain text",
1483+
content: "just some random text",
1484+
}),
1485+
).rejects.toThrow(ValidationError);
1486+
});
1487+
1488+
it("should throw ValidationError when any content URI in array is invalid", async () => {
1489+
await expect(
1490+
hypercertOps.addAttachment({
1491+
subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc",
1492+
title: "Attachment with mixed content",
1493+
content: ["https://example.com/valid.pdf", "not-a-valid-uri"],
1494+
}),
1495+
).rejects.toThrow(ValidationError);
1496+
});
1497+
1498+
it("should accept valid non-http URI schemes in content", async () => {
1499+
const result = await hypercertOps.addAttachment({
1500+
subjects: "at://did:plc:test/org.hypercerts.claim.activity/abc",
1501+
title: "IPFS Attachment",
1502+
content: "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
1503+
});
1504+
1505+
expect(result.uri).toContain("attachment");
1506+
const call = mockAgent.com.atproto.repo.createRecord.mock.calls[0][0];
1507+
expect(call.record.content).toEqual([
1508+
{
1509+
$type: "org.hypercerts.defs#uri",
1510+
uri: "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
1511+
},
1512+
]);
1513+
});
14671514
});
14681515

14691516
describe("addMeasurement", () => {

0 commit comments

Comments
 (0)