Refine link preview domain restrictions.
This commit is contained in:
parent
a917dace6e
commit
8baf07a11c
5 changed files with 150 additions and 90 deletions
|
@ -63,7 +63,7 @@ class TextStoryPostLinkEntryFragment : KeyboardEntryDialogFragment(
|
||||||
if (linkPreviewState != null) {
|
if (linkPreviewState != null) {
|
||||||
val url = linkPreviewState.linkPreview.map { it.url }.orElseGet { linkPreviewState.activeUrlForError }
|
val url = linkPreviewState.linkPreview.map { it.url }.orElseGet { linkPreviewState.activeUrlForError }
|
||||||
|
|
||||||
if (LinkUtil.isLegalUrl(url, false, true)) {
|
if (LinkUtil.isValidTextStoryPostPreview(url)) {
|
||||||
viewModel.setLinkPreview(url)
|
viewModel.setLinkPreview(url)
|
||||||
dismissAllowingStateLoss()
|
dismissAllowingStateLoss()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,88 +0,0 @@
|
||||||
package org.thoughtcrime.securesms.util;
|
|
||||||
|
|
||||||
import android.text.TextUtils;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import org.signal.core.util.SetUtil;
|
|
||||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import okhttp3.HttpUrl;
|
|
||||||
|
|
||||||
public final class LinkUtil {
|
|
||||||
|
|
||||||
private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$");
|
|
||||||
private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$");
|
|
||||||
private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$");
|
|
||||||
private static final Pattern ILLEGAL_CHARACTERS_PATTERN = Pattern.compile("[\u202C\u202D\u202E\u2500-\u25FF]");
|
|
||||||
|
|
||||||
private static final Set<String> INVALID_TOP_LEVEL_DOMAINS = SetUtil.newHashSet("onion", "i2p");
|
|
||||||
|
|
||||||
private LinkUtil() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return True if URL is valid for link previews.
|
|
||||||
*/
|
|
||||||
public static boolean isValidPreviewUrl(@Nullable String linkUrl) {
|
|
||||||
if (linkUrl == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (StickerUrl.isValidShareLink(linkUrl)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpUrl url = HttpUrl.parse(linkUrl);
|
|
||||||
return url != null &&
|
|
||||||
!TextUtils.isEmpty(url.scheme()) &&
|
|
||||||
"https".equals(url.scheme()) &&
|
|
||||||
isLegalUrl(linkUrl, false, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return True if URL is valid, mostly useful for linkifying.
|
|
||||||
*/
|
|
||||||
public static boolean isLegalUrl(@NonNull String url) {
|
|
||||||
return isLegalUrl(url, true, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isLegalUrl(@NonNull String url, boolean skipTopLevelDomainValidation, boolean requireTopLevelDomain) {
|
|
||||||
if (ILLEGAL_CHARACTERS_PATTERN.matcher(url).find()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Matcher matcher = DOMAIN_PATTERN.matcher(url);
|
|
||||||
|
|
||||||
if (matcher.matches()) {
|
|
||||||
String domain = Objects.requireNonNull(matcher.group(2));
|
|
||||||
String cleanedDomain = domain.replaceAll("\\.", "");
|
|
||||||
String topLevelDomain = parseTopLevelDomain(domain);
|
|
||||||
|
|
||||||
boolean validCharacters = ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() ||
|
|
||||||
ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches();
|
|
||||||
|
|
||||||
boolean validTopLevelDomain = (skipTopLevelDomainValidation || !INVALID_TOP_LEVEL_DOMAINS.contains(topLevelDomain)) &&
|
|
||||||
(!requireTopLevelDomain || topLevelDomain != null);
|
|
||||||
|
|
||||||
return validCharacters && validTopLevelDomain;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static @Nullable String parseTopLevelDomain(@NonNull String domain) {
|
|
||||||
int periodIndex = domain.lastIndexOf(".");
|
|
||||||
|
|
||||||
if (periodIndex >= 0 && periodIndex < domain.length() - 1) {
|
|
||||||
return domain.substring(periodIndex + 1);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
package org.thoughtcrime.securesms.util
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import org.thoughtcrime.securesms.stickers.StickerUrl
|
||||||
|
import java.util.Objects
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilities for validating various links for multiple situations.
|
||||||
|
*/
|
||||||
|
object LinkUtil {
|
||||||
|
private val DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$")
|
||||||
|
private val ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$")
|
||||||
|
private val ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$")
|
||||||
|
private val ILLEGAL_CHARACTERS_PATTERN = Pattern.compile("[\u202C\u202D\u202E\u2500-\u25FF]")
|
||||||
|
|
||||||
|
private val INVALID_DOMAINS = listOf("example", "example\\.com", "example\\.net", "example\\.org", "i2p", "invalid", "localhost", "onion", "test")
|
||||||
|
private val INVALID_DOMAINS_REGEX: Regex = Regex("^(.+\\.)?(${INVALID_DOMAINS.joinToString("|")})\\.?\$")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link previews must have all valid URL characters, an allowed domain if present, and must include https://
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun isValidPreviewUrl(linkUrl: String?): Boolean {
|
||||||
|
if (linkUrl == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StickerUrl.isValidShareLink(linkUrl)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val (isLegal, domain) = isLegalUrlInternal(linkUrl)
|
||||||
|
|
||||||
|
if (!isLegal || domain?.matches(INVALID_DOMAINS_REGEX) == true) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return HttpUrl.parse(linkUrl)?.scheme() == "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text story link previews must have all valid URL characters, a present and allowed domain, and must have a TLD.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun isValidTextStoryPostPreview(url: String): Boolean {
|
||||||
|
val (isLegal, domain) = isLegalUrlInternal(url)
|
||||||
|
|
||||||
|
if (!isLegal || domain == null || domain.matches(INVALID_DOMAINS_REGEX)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.lastIndexOf('.', domain.lastIndex) != -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL is legal if it has all valid URL characters.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun isLegalUrl(url: String): Boolean {
|
||||||
|
return isLegalUrlInternal(url).isLegal
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLegalUrlInternal(url: String): LegalCharactersResult {
|
||||||
|
if (ILLEGAL_CHARACTERS_PATTERN.matcher(url).find()) {
|
||||||
|
return LegalCharactersResult(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val matcher = DOMAIN_PATTERN.matcher(url)
|
||||||
|
if (!matcher.matches()) {
|
||||||
|
return LegalCharactersResult(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val domain = Objects.requireNonNull(matcher.group(2))
|
||||||
|
val cleanedDomain = domain.replace("\\.".toRegex(), "")
|
||||||
|
|
||||||
|
return LegalCharactersResult(
|
||||||
|
isLegal = ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() || ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches(),
|
||||||
|
domain = domain
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LegalCharactersResult(val isLegal: Boolean, val domain: String? = null)
|
||||||
|
}
|
|
@ -36,7 +36,16 @@ public class LinkUtilTest_isLegal {
|
||||||
{ "кц.рф\u2500", false },
|
{ "кц.рф\u2500", false },
|
||||||
{ "кц.рф\u25AA", false },
|
{ "кц.рф\u25AA", false },
|
||||||
{ "кц.рф\u25FF", false },
|
{ "кц.рф\u25FF", false },
|
||||||
{ "", false }
|
{ "", false },
|
||||||
|
{ "cool.example", true },
|
||||||
|
{ "cool.example.com", true },
|
||||||
|
{ "cool.example.net", true },
|
||||||
|
{ "cool.example.org", true },
|
||||||
|
{ "cool.invalid", true },
|
||||||
|
{ "cool.localhost", true },
|
||||||
|
{ "localhost", true },
|
||||||
|
{ "https://localhost", true },
|
||||||
|
{ "cool.test", true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
package org.thoughtcrime.securesms.util
|
||||||
|
|
||||||
|
import junit.framework.TestCase
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.Parameterized
|
||||||
|
|
||||||
|
@RunWith(Parameterized::class)
|
||||||
|
class LinkUtilTest_isValidPreviewUrl(private val input: String, private val output: Boolean) {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLegal() {
|
||||||
|
TestCase.assertEquals(output, LinkUtil.isValidPreviewUrl(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Parameterized.Parameters
|
||||||
|
@JvmStatic
|
||||||
|
fun data(): Collection<Array<Any>> {
|
||||||
|
return listOf(
|
||||||
|
arrayOf("google.com", false),
|
||||||
|
arrayOf("foo.google.com", false),
|
||||||
|
arrayOf("https://foo.google.com", true),
|
||||||
|
arrayOf("https://foo.google.com.", true),
|
||||||
|
arrayOf("https://foo.google.com/some/path.html", true),
|
||||||
|
arrayOf("кц.рф", false),
|
||||||
|
arrayOf("https://кц.рф/some/path", true),
|
||||||
|
arrayOf("https://abcdefg.onion", false),
|
||||||
|
arrayOf("https://abcdefg.i2p", false),
|
||||||
|
arrayOf("http://кц.com", false),
|
||||||
|
arrayOf("кц.com", false),
|
||||||
|
arrayOf("http://asĸ.com", false),
|
||||||
|
arrayOf("http://foo.кц.рф", false),
|
||||||
|
arrayOf("кц.рф\u202C", false),
|
||||||
|
arrayOf("кц.рф\u202D", false),
|
||||||
|
arrayOf("кц.рф\u202E", false),
|
||||||
|
arrayOf("кц.рф\u2500", false),
|
||||||
|
arrayOf("кц.рф\u25AA", false),
|
||||||
|
arrayOf("кц.рф\u25FF", false),
|
||||||
|
arrayOf("", false),
|
||||||
|
arrayOf("https://cool.example", false),
|
||||||
|
arrayOf("https://cool.example.com", false),
|
||||||
|
arrayOf("https://cool.example.net", false),
|
||||||
|
arrayOf("https://cool.example.org", false),
|
||||||
|
arrayOf("https://cool.invalid", false),
|
||||||
|
arrayOf("https://cool.localhost", false),
|
||||||
|
arrayOf("https://localhost", false),
|
||||||
|
arrayOf("https://cool.test", false),
|
||||||
|
arrayOf("https://cool.invalid.com", true),
|
||||||
|
arrayOf("https://cool.localhost.signal.org", true),
|
||||||
|
arrayOf("https://cool.test.blarg.gov", true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue