improve url validation for auth47 and lnurl-auth
What changed, and why it matters
This commit tightens the checks on web addresses used during two login-style features, Auth47 and LNURL-auth. Previously, a malicious or malformed callback address could use insecure schemes such as plain HTTP on the regular internet or non-web schemes like file:// or ftp://. The patch now requires callbacks to be HTTPS, a special 'srbn' scheme, or a Tor .onion address over HTTP, and adds tests to confirm the new behavior. This reduces the risk that an attacker could trick the wallet into sending authentication data to an unintended destination.
Review the implementation of Utils.isSecureUrl() in the drongo submodule to confirm it correctly handles scheme case, userinfo, and .onion host detection. Ensure the validation is applied consistently wherever user-supplied URIs are parsed for network callbacks. Consider whether additional schemes or redirect behaviors need similar restrictions.
Security signals we found
Callback URL scheme validation added for authentication protocols
Rejection of clearnet HTTP and non-HTTP schemes (file://, ftp://)
Bech32 HRP whitelist ('lnurl') added in LnurlAuth
New unit tests assert rejection of insecure callback URLs
Evidence from the diff
The patch adds a shared Utils.isSecureUrl() check in Auth47 and LnurlAuth constructors. For Auth47, it now rejects callback parameters that are not HTTPS, HTTP .onion, or srbn://, and removes the old logic that accepted any string starting with ‘http’. For LnurlAuth, it validates the Bech32 human-readable part is exactly ‘lnurl’ and rejects decoded URLs that are not HTTPS or HTTP .onion. New unit tests cover accepted and rejected cases for both protocols. The actual implementation of Utils.isSecureUrl() is in the drongo submodule and is referenced but not shown in the diff.
Changed components
com.sparrowwallet.sparrow.net.Auth47com.sparrowwallet.sparrow.net.LnurlAuthcom.sparrowwallet.drongo.Utils (referenced validation helper)Auth47Test and LnurlAuthTest unit testsInspect captured patch +112 / −10
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/Auth47.java b/src/main/java/com/sparrowwallet/sparrow/net/Auth47.java
index 83d6e17..5baf610 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/Auth47.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/Auth47.java
@@ -4,6 +4,7 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyPurpose;
+import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
@@ -69,19 +70,17 @@ public class Auth47 {
this.srbnName = srbnUrl.getUserInfo();
this.callback = new URI(HTTPS_PROTOCOL + srbnUrl.getHost()).toURL();
} else {
- this.callback = new URI(strCallback).toURL();
+ URI callbackUri = new URI(strCallback);
+ if(!Utils.isSecureUrl(callbackUri)) {
+ throw new IllegalArgumentException("Invalid callback parameter (not https, http .onion or srbn): " + strCallback);
+ }
+ this.callback = callbackUri.toURL();
}
this.expiry = parameterMap.get("e");
this.resource = parameterMap.get("r");
if(resource == null) {
- if(srbn) {
- this.resource = "srbn";
- } else if(strCallback.startsWith("http")) {
- this.resource = strCallback;
- } else {
- throw new IllegalArgumentException("Invalid callback parameter (not http/s or srbn): " + strCallback);
- }
+ this.resource = srbn ? "srbn" : strCallback;
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/LnurlAuth.java b/src/main/java/com/sparrowwallet/sparrow/net/LnurlAuth.java
index 3311e0b..1566caa 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/LnurlAuth.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/LnurlAuth.java
@@ -41,9 +41,17 @@ public class LnurlAuth {
public LnurlAuth(URI uri) throws MalformedURLException, URISyntaxException {
String lnurl = uri.getSchemeSpecificPart();
Bech32.Bech32Data bech32 = Bech32.decode(lnurl, 2000);
+ if(!"lnurl".equals(bech32.hrp)) {
+ throw new IllegalArgumentException("LNURL-auth bech32 prefix must be lnurl");
+ }
+
byte[] urlBytes = Bech32.convertBits(bech32.data, 0, bech32.data.length, 5, 8, false);
String strUrl = new String(urlBytes, StandardCharsets.UTF_8);
- this.url = new URI(strUrl).toURL();
+ URI decodedUri = new URI(strUrl);
+ if(!Utils.isSecureUrl(decodedUri)) {
+ throw new IllegalArgumentException("LNURL-auth URL must be https or http .onion");
+ }
+ this.url = decodedUri.toURL();
Map<String, String> parameterMap = new LinkedHashMap<>();
String query = url.getQuery();
diff --git a/src/test/java/com/sparrowwallet/sparrow/net/Auth47Test.java b/src/test/java/com/sparrowwallet/sparrow/net/Auth47Test.java
new file mode 100644
index 0000000..c6e5985
--- /dev/null
+++ b/src/test/java/com/sparrowwallet/sparrow/net/Auth47Test.java
@@ -0,0 +1,38 @@
+package com.sparrowwallet.sparrow.net;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class Auth47Test {
+ @Test
+ public void acceptsHttpsCallbacksWithResource() throws Exception {
+ Auth47 auth47 = new Auth47(new URI("auth47://nonce?c=https://example.com/auth&r=example"));
+
+ assertEquals("https", auth47.getCallback().getProtocol());
+ assertEquals("example.com", auth47.getCallback().getHost());
+ }
+
+ @Test
+ public void acceptsSrbnCallbacks() throws Exception {
+ Auth47 auth47 = new Auth47(new URI("auth47://nonce?c=srbn://alice@relay.example.com"));
+
+ assertEquals("https", auth47.getCallback().getProtocol());
+ assertEquals("relay.example.com", auth47.getCallback().getHost());
+ }
+
+ @Test
+ public void rejectsHttpClearnetCallbacks() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new Auth47(new URI("auth47://nonce?c=http://example.com/auth&r=example")));
+ }
+
+ @Test
+ public void rejectsNonHttpCallbacksWithResource() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new Auth47(new URI("auth47://nonce?c=file:///tmp/auth47&r=example")));
+ }
+}
diff --git a/src/test/java/com/sparrowwallet/sparrow/net/LnurlAuthTest.java b/src/test/java/com/sparrowwallet/sparrow/net/LnurlAuthTest.java
new file mode 100644
index 0000000..66ec71b
--- /dev/null
+++ b/src/test/java/com/sparrowwallet/sparrow/net/LnurlAuthTest.java
@@ -0,0 +1,57 @@
+package com.sparrowwallet.sparrow.net;
+
+import com.sparrowwallet.drongo.protocol.Bech32;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class LnurlAuthTest {
+ private static final String K1 = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
+
+ @Test
+ public void acceptsHttpsCallbacks() throws Exception {
+ LnurlAuth lnurlAuth = new LnurlAuth(lightningUri("https://example.com/lnurl-auth?tag=login&k1=" + K1));
+
+ assertEquals("example.com", lnurlAuth.getDomain());
+ assertEquals("login to example.com", lnurlAuth.getLoginMessage());
+ }
+
+ @Test
+ public void acceptsHttpOnionCallbacks() throws Exception {
+ LnurlAuth lnurlAuth = new LnurlAuth(lightningUri("http://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/lnurl-auth?tag=login&k1=" + K1));
+
+ assertEquals("abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion", lnurlAuth.getDomain());
+ }
+
+ @Test
+ public void rejectsHttpClearnetCallbacks() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new LnurlAuth(lightningUri("http://example.com/lnurl-auth?tag=login&k1=" + K1)));
+ }
+
+ @Test
+ public void rejectsNonHttpCallbacks() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new LnurlAuth(lightningUri("ftp://example.com/lnurl-auth?tag=login&k1=" + K1)));
+ }
+
+ @Test
+ public void rejectsNonLnurlBech32Prefix() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new LnurlAuth(lightningUri("lnurlx", "https://example.com/lnurl-auth?tag=login&k1=" + K1)));
+ }
+
+ private static URI lightningUri(String url) throws Exception {
+ return lightningUri("lnurl", url);
+ }
+
+ private static URI lightningUri(String prefix, String url) throws Exception {
+ byte[] urlBytes = url.getBytes(StandardCharsets.UTF_8);
+ byte[] lnurlData = Bech32.convertBits(urlBytes, 0, urlBytes.length, 8, 5, true);
+ return new URI("lightning:" + Bech32.encode(prefix, Bech32.Encoding.BECH32, lnurlData));
+ }
+}
Why this scored 63/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.