@stellar/typescript-wallet-sdk 3.0.2 → 3.0.3

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.
@@ -14,3 +14,21 @@ export type IsValidSep7UriResult = {
14
14
  result: boolean;
15
15
  reason?: string;
16
16
  };
17
+ /**
18
+ * Matches a fully qualified domain name (e.g. "example.com", "sub.example.co.uk").
19
+ *
20
+ * Rejects userinfo ('@'), ports (':'), paths/queries ('/', '?'), scheme
21
+ * prefixes ('//'), bare hostnames like 'localhost', and raw IPv4/IPv6
22
+ * addresses (the all-numeric final label fails the alphabetic TLD rule).
23
+ *
24
+ * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#verifying-the-signature step 3.
25
+ */
26
+ export declare const FULLY_QUALIFIED_DOMAIN_NAME_REGEX: RegExp;
27
+ /**
28
+ * Returns true if the given string is a valid fully qualified domain name.
29
+ *
30
+ * @param {string} domain the domain string to validate.
31
+ *
32
+ * @returns {boolean} true if 'domain' is a valid fully qualified domain name.
33
+ */
34
+ export declare const isValidFullyQualifiedDomainName: (domain: string) => boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stellar/typescript-wallet-sdk",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "engines": {
5
5
  "node": ">=20"
6
6
  },
@@ -18,3 +18,25 @@ export type IsValidSep7UriResult = {
18
18
  result: boolean;
19
19
  reason?: string;
20
20
  };
21
+
22
+ /**
23
+ * Matches a fully qualified domain name (e.g. "example.com", "sub.example.co.uk").
24
+ *
25
+ * Rejects userinfo ('@'), ports (':'), paths/queries ('/', '?'), scheme
26
+ * prefixes ('//'), bare hostnames like 'localhost', and raw IPv4/IPv6
27
+ * addresses (the all-numeric final label fails the alphabetic TLD rule).
28
+ *
29
+ * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md#verifying-the-signature step 3.
30
+ */
31
+ export const FULLY_QUALIFIED_DOMAIN_NAME_REGEX =
32
+ /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
33
+
34
+ /**
35
+ * Returns true if the given string is a valid fully qualified domain name.
36
+ *
37
+ * @param {string} domain the domain string to validate.
38
+ *
39
+ * @returns {boolean} true if 'domain' is a valid fully qualified domain name.
40
+ */
41
+ export const isValidFullyQualifiedDomainName = (domain: string): boolean =>
42
+ FULLY_QUALIFIED_DOMAIN_NAME_REGEX.test(domain);
@@ -1,5 +1,9 @@
1
1
  import { Keypair, Networks, StellarToml } from "@stellar/stellar-sdk";
2
- import { Sep7OperationType, URI_MSG_MAX_LENGTH } from "../Types";
2
+ import {
3
+ Sep7OperationType,
4
+ URI_MSG_MAX_LENGTH,
5
+ isValidFullyQualifiedDomainName,
6
+ } from "../Types";
3
7
  import { Sep7LongMsgError } from "../Exceptions";
4
8
 
5
9
  /**
@@ -234,6 +238,15 @@ export abstract class Sep7Base {
234
238
  return false;
235
239
  }
236
240
 
241
+ // SEP-7 step 3: "If the origin_domain is not a valid fully qualified
242
+ // domain name, do not allow the user to sign the transaction." This must
243
+ // be enforced before resolving the stellar.toml, otherwise a malformed
244
+ // origin_domain (e.g. containing userinfo, a port, or a path) could
245
+ // redirect the trust-anchoring TOML fetch to an attacker-controlled host.
246
+ if (!isValidFullyQualifiedDomainName(originDomain)) {
247
+ return false;
248
+ }
249
+
237
250
  try {
238
251
  const toml = await StellarToml.Resolver.resolve(originDomain);
239
252
  const signingKey = toml.URI_REQUEST_SIGNING_KEY;
@@ -8,6 +8,7 @@ import {
8
8
  WEB_STELLAR_SCHEME,
9
9
  URI_MSG_MAX_LENGTH,
10
10
  URI_REPLACE_MAX_LENGTH,
11
+ isValidFullyQualifiedDomainName,
11
12
  } from "../Types";
12
13
  import {
13
14
  Sep7InvalidUriError,
@@ -42,6 +43,7 @@ export const isValidSep7Uri = (uri: string): IsValidSep7UriResult => {
42
43
  url.searchParams.get("network_passphrase") || Networks.PUBLIC;
43
44
  const destination = url.searchParams.get("destination");
44
45
  const msg = url.searchParams.get("msg");
46
+ const originDomain = url.searchParams.get("origin_domain");
45
47
 
46
48
  if (![Sep7OperationType.tx, Sep7OperationType.pay].includes(type)) {
47
49
  return {
@@ -98,6 +100,18 @@ export const isValidSep7Uri = (uri: string): IsValidSep7UriResult => {
98
100
  };
99
101
  }
100
102
 
103
+ // 'origin_domain' is optional (SEP-7), but when present it must be a valid
104
+ // fully qualified domain name (SEP-7 step 3), otherwise a malformed value
105
+ // could later redirect the trust-anchoring stellar.toml fetch in
106
+ // Sep7Base.verifySignature() to an attacker-controlled host.
107
+ if (originDomain && !isValidFullyQualifiedDomainName(originDomain)) {
108
+ return {
109
+ result: false,
110
+ reason:
111
+ "the provided 'origin_domain' parameter is not a valid fully qualified domain name",
112
+ };
113
+ }
114
+
101
115
  return {
102
116
  result: true,
103
117
  };
package/test/sep7.test.ts CHANGED
@@ -226,6 +226,115 @@ describe("Sep7Base", () => {
226
226
 
227
227
  expect(await uri.verifySignature()).toBe(true);
228
228
  });
229
+
230
+ it("verifySignature() returns false and never resolves the toml when origin_domain has userinfo (RFC 3986 confusion)", async () => {
231
+ const uriStr =
232
+ "web+stellar:tx?xdr=test&origin_domain=stellar.org%40evil.com&signature=sig";
233
+ const uri = new Sep7Tx(uriStr);
234
+
235
+ const resolveSpy = jest
236
+ .spyOn(StellarToml.Resolver, "resolve")
237
+ .mockResolvedValue({
238
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
239
+ });
240
+ // jest.spyOn() re-spying on an already-mocked function preserves the
241
+ // existing call history, so clear it to accurately assert non-invocation.
242
+ resolveSpy.mockClear();
243
+
244
+ expect(await uri.verifySignature()).toBe(false);
245
+ expect(resolveSpy).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it("verifySignature() returns false and never resolves the toml when origin_domain has a port", async () => {
249
+ const uriStr =
250
+ "web+stellar:tx?xdr=test&origin_domain=example.com%3A8443&signature=sig";
251
+ const uri = new Sep7Tx(uriStr);
252
+
253
+ const resolveSpy = jest
254
+ .spyOn(StellarToml.Resolver, "resolve")
255
+ .mockResolvedValue({
256
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
257
+ });
258
+ // jest.spyOn() re-spying on an already-mocked function preserves the
259
+ // existing call history, so clear it to accurately assert non-invocation.
260
+ resolveSpy.mockClear();
261
+
262
+ expect(await uri.verifySignature()).toBe(false);
263
+ expect(resolveSpy).not.toHaveBeenCalled();
264
+ });
265
+
266
+ it("verifySignature() returns false and never resolves the toml when origin_domain has a path", async () => {
267
+ const uriStr =
268
+ "web+stellar:tx?xdr=test&origin_domain=example.com%2Fpath&signature=sig";
269
+ const uri = new Sep7Tx(uriStr);
270
+
271
+ const resolveSpy = jest
272
+ .spyOn(StellarToml.Resolver, "resolve")
273
+ .mockResolvedValue({
274
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
275
+ });
276
+ // jest.spyOn() re-spying on an already-mocked function preserves the
277
+ // existing call history, so clear it to accurately assert non-invocation.
278
+ resolveSpy.mockClear();
279
+
280
+ expect(await uri.verifySignature()).toBe(false);
281
+ expect(resolveSpy).not.toHaveBeenCalled();
282
+ });
283
+
284
+ it("verifySignature() returns false and never resolves the toml when origin_domain is a raw IPv4 address", async () => {
285
+ const uriStr =
286
+ "web+stellar:tx?xdr=test&origin_domain=127.0.0.1&signature=sig";
287
+ const uri = new Sep7Tx(uriStr);
288
+
289
+ const resolveSpy = jest
290
+ .spyOn(StellarToml.Resolver, "resolve")
291
+ .mockResolvedValue({
292
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
293
+ });
294
+ // jest.spyOn() re-spying on an already-mocked function preserves the
295
+ // existing call history, so clear it to accurately assert non-invocation.
296
+ resolveSpy.mockClear();
297
+
298
+ expect(await uri.verifySignature()).toBe(false);
299
+ expect(resolveSpy).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it("verifySignature() returns false and never resolves the toml when origin_domain is 'localhost'", async () => {
303
+ const uriStr =
304
+ "web+stellar:tx?xdr=test&origin_domain=localhost&signature=sig";
305
+ const uri = new Sep7Tx(uriStr);
306
+
307
+ const resolveSpy = jest
308
+ .spyOn(StellarToml.Resolver, "resolve")
309
+ .mockResolvedValue({
310
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
311
+ });
312
+ // jest.spyOn() re-spying on an already-mocked function preserves the
313
+ // existing call history, so clear it to accurately assert non-invocation.
314
+ resolveSpy.mockClear();
315
+
316
+ expect(await uri.verifySignature()).toBe(false);
317
+ expect(resolveSpy).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it("verifySignature() returns false and never resolves the toml when origin_domain has an injected CRLF", async () => {
321
+ // 'stellar.org%0d%0aevil.com' URL-decodes to 'stellar.org\r\nevil.com'.
322
+ const uriStr =
323
+ "web+stellar:tx?xdr=test&origin_domain=stellar.org%0d%0aevil.com&signature=sig";
324
+ const uri = new Sep7Tx(uriStr);
325
+
326
+ const resolveSpy = jest
327
+ .spyOn(StellarToml.Resolver, "resolve")
328
+ .mockResolvedValue({
329
+ URI_REQUEST_SIGNING_KEY: testKp1.publicKey,
330
+ });
331
+ // jest.spyOn() re-spying on an already-mocked function preserves the
332
+ // existing call history, so clear it to accurately assert non-invocation.
333
+ resolveSpy.mockClear();
334
+
335
+ expect(await uri.verifySignature()).toBe(false);
336
+ expect(resolveSpy).not.toHaveBeenCalled();
337
+ });
229
338
  });
230
339
 
231
340
  describe("Sep7Tx", () => {
@@ -589,6 +698,32 @@ describe("sep7Parser", () => {
589
698
  ).toBe(true);
590
699
  });
591
700
 
701
+ it("isValidSep7Uri(uri) returns true when 'origin_domain' is absent", () => {
702
+ expect(
703
+ isValidSep7Uri(
704
+ "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens",
705
+ ).result,
706
+ ).toBe(true);
707
+ });
708
+
709
+ it("isValidSep7Uri(uri) returns true when 'origin_domain' is a valid fully qualified domain name", () => {
710
+ expect(
711
+ isValidSep7Uri(
712
+ "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=someDomain.com",
713
+ ).result,
714
+ ).toBe(true);
715
+ });
716
+
717
+ it("isValidSep7Uri(uri) returns 'false' with 'reason' when 'origin_domain' is not a valid fully qualified domain name", () => {
718
+ const validation = isValidSep7Uri(
719
+ "web+stellar:pay?destination=GCALNQQBXAPZ2WIRSDDBMSTAKCUH5SG6U76YBFLQLIXJTF7FE5AX7AOO&amount=120.1234567&memo=skdjfasf&msg=pay%20me%20with%20lumens&origin_domain=stellar.org%40evil.com",
720
+ );
721
+ expect(validation.result).toBe(false);
722
+ expect(validation.reason).toBe(
723
+ "the provided 'origin_domain' parameter is not a valid fully qualified domain name",
724
+ );
725
+ });
726
+
592
727
  it("isValidSep7Uri(uri) returns 'false' with 'reason' when it is not valid in some way", () => {
593
728
  const validation1 = isValidSep7Uri(
594
729
  "not-a-stellar-uri:tx?xdr=AAAAAP%2Byw%2BZEuNg533pUmwlYxfrq6%2FBoMJqiJ8vuQhf6rHWmAAAAZAB8NHAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAYAAAABSFVHAAAAAABAH0wIyY3BJBS2qHdRPAV80M8hF7NBpxRjXyjuT9kEbH%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FAAAAAAAAAAA%3D&callback=url%3Ahttps%3A%2F%2FsomeSigningService.com%2Fa8f7asdfkjha&pubkey=GAU2ZSYYEYO5S5ZQSMMUENJ2TANY4FPXYGGIMU6GMGKTNVDG5QYFW6JS&msg=order%20number%2024",
@@ -834,7 +969,9 @@ describe("sep7Parser", () => {
834
969
 
835
970
  it("Sep7Tx.getReplacements() throws on replace param missing hints", () => {
836
971
  const uri = new Sep7Tx(
837
- `web+stellar:tx?xdr=test&replace=${encodeURIComponent("sourceAccount:X")}`,
972
+ `web+stellar:tx?xdr=test&replace=${encodeURIComponent(
973
+ "sourceAccount:X",
974
+ )}`,
838
975
  );
839
976
 
840
977
  expect(() => uri.getReplacements()).toThrow(Sep7InvalidUriError);