diff --git a/Source/AudibleUtilities/AudibleApiStorage.cs b/Source/AudibleUtilities/AudibleApiStorage.cs
index c3011a18..ddcee1f1 100644
--- a/Source/AudibleUtilities/AudibleApiStorage.cs
+++ b/Source/AudibleUtilities/AudibleApiStorage.cs
@@ -62,24 +62,16 @@ public static class AudibleApiStorage
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
public static string GetIdentityTokensJsonPath(string username, string? localeName)
{
- var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username));
- var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName));
+ var usernameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(username);
+ var localeNameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(localeName ?? string.Empty);
- return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens";
+ return $"$.Accounts[?(@.AccountId == '{usernameEscaped}' && @.IdentityTokens.LocaleName == '{localeNameEscaped}')].IdentityTokens";
}
- private static string trimSurroundingQuotes(string str)
- {
- // SubString algo is better than .Trim("\"")
- // orig string "
- // json string "\""
- // Eg:
- // => str.Trim("\"")
- // output \
- // vs
- // => str.Substring(1, str.Length - 2)
- // output \"
- // also works with surrounding single quotes
- return str.Substring(1, str.Length - 2);
- }
+ ///
+ /// Escape a value for use inside single-quoted Newtonsoft JSONPath filter literals.
+ /// See https://www.newtonsoft.com/json/help/html/QueryJsonSelectTokenEscaped.htm
+ ///
+ internal static string EscapeNewtonsoftJsonPathSingleQuotedLiteral(string value)
+ => value.Replace("\\", @"\\").Replace("'", @"\'");
}
diff --git a/Source/AudibleUtilities/Widevine/Cdm.cs b/Source/AudibleUtilities/Widevine/Cdm.cs
index faf9def5..04c69f87 100644
--- a/Source/AudibleUtilities/Widevine/Cdm.cs
+++ b/Source/AudibleUtilities/Widevine/Cdm.cs
@@ -290,7 +290,7 @@ public partial class Cdm
private static uint RandomUint()
{
var bts = new byte[4];
- new Random().NextBytes(bts);
+ RandomNumberGenerator.Fill(bts);
return BitConverter.ToUInt32(bts, 0);
}
}
diff --git a/Source/_Tests/AudibleUtilities.Tests/GetIdentityTokensJsonPathTests.cs b/Source/_Tests/AudibleUtilities.Tests/GetIdentityTokensJsonPathTests.cs
new file mode 100644
index 00000000..f1487d35
--- /dev/null
+++ b/Source/_Tests/AudibleUtilities.Tests/GetIdentityTokensJsonPathTests.cs
@@ -0,0 +1,87 @@
+using AssertionHelper;
+using AudibleUtilities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace AccountsTests;
+
+[TestClass]
+public class GetIdentityTokensJsonPathTests
+{
+ private const string MarkerProperty = "Marker";
+
+ private static JObject CreateAccountsJson(params (string accountId, string locale, string marker)[] accounts)
+ {
+ var accountsArray = new JArray();
+ foreach (var (accountId, locale, marker) in accounts)
+ {
+ accountsArray.Add(new JObject
+ {
+ ["AccountId"] = accountId,
+ ["IdentityTokens"] = new JObject
+ {
+ ["LocaleName"] = locale,
+ [MarkerProperty] = marker
+ }
+ });
+ }
+
+ return new JObject { ["Accounts"] = accountsArray };
+ }
+
+ private static string? SelectMarker(JObject root, string jsonPath)
+ => root.SelectToken(jsonPath)?[MarkerProperty]?.Value();
+
+ [TestMethod]
+ public void Normal_email_matches_expected_account()
+ {
+ var root = CreateAccountsJson(
+ ("other@example.com", "us", "target"),
+ ("someone@example.com", "us", "other"));
+
+ var path = AudibleApiStorage.GetIdentityTokensJsonPath("other@example.com", "us");
+
+ SelectMarker(root, path).Should().Be("target");
+ }
+
+ [TestMethod]
+ public void Apostrophe_in_account_id_matches_expected_account()
+ {
+ var root = CreateAccountsJson(("o'hara@example.com", "us", "target"));
+
+ var path = AudibleApiStorage.GetIdentityTokensJsonPath("o'hara@example.com", "us");
+
+ SelectMarker(root, path).Should().Be("target");
+ }
+
+ [TestMethod]
+ public void Backslash_in_account_id_matches_expected_account()
+ {
+ var root = CreateAccountsJson((@"a\b@c.com", "us", "target"));
+
+ var path = AudibleApiStorage.GetIdentityTokensJsonPath(@"a\b@c.com", "us");
+
+ SelectMarker(root, path).Should().Be("target");
+ }
+
+ [TestMethod]
+ public void Injection_payload_does_not_match_unrelated_account()
+ {
+ var root = CreateAccountsJson(
+ ("' || @.AccountId == 'evil' || @.AccountId == '", "us", "payload"),
+ ("evil", "us", "wrong"));
+
+ var path = AudibleApiStorage.GetIdentityTokensJsonPath(
+ "' || @.AccountId == 'evil' || @.AccountId == '",
+ "us");
+
+ SelectMarker(root, path).Should().Be("payload");
+ }
+
+ [TestMethod]
+ public void EscapeNewtonsoftJsonPathSingleQuotedLiteral_escapes_backslash_before_quote()
+ {
+ AudibleApiStorage.EscapeNewtonsoftJsonPathSingleQuotedLiteral(@"a\b").Should().Be(@"a\\b");
+ AudibleApiStorage.EscapeNewtonsoftJsonPathSingleQuotedLiteral("o'hara").Should().Be(@"o\'hara");
+ }
+}