保护Java URL编码和解码
#java #engineering #codesecurity

URL编码是一种确保您的URL仅包含有效字符的方法,以便接收服务器可以正确解释它。根据RFC 3986标准,URI(是URL的超集)only contain a limited set of characters由数字,字母和一些图形符号组成,所有图形符号都在ASCII字符集内。

如果URL包含此有限集之外的字符,则字符必须为percent-encoded。编码百分比意味着角色被转换为八位八位的两位数十六进制表示,而%逃生角色则在其前面。当在ASCII中使用时,应将相同的过程应用于分界符(例如&/?#)。

相比之下,URL解码是一种将一个百分比的URL转换回其原始形式的方法,可恢复沿途的任何非标准字符。

重要的是要了解编码不是加密。加密是关于使用秘密密钥修改信息,因此除了发送到的一方以外,任何人都无法使用原始信息。相比之下,URL编码的目的不是将URL的一部分隐藏在外部观察者中,而是要确保接收服务器容易易于解释URL;并防止构建和发送URL的客户的用户操纵URL。

未能编码URL可能会导致各种问题。例如,您的应用程序可能无法组合URL将其发送到服务器。此外,接收URL的服务器可能无法正确解析它,从而导致错误响应。另一个风险是可以篡改未编码的URL,使您的申请暴露于潜在的安全威胁。

每种编程语言提供一个或多个用于编码和解码URL的API。本文讨论了Java,为什么URL编码和解码很重要,以及如何正确处理。

Java中的URL编码和解码是什么?

专门谈论Java时,URL编码和解码对于以下用例很重要:

  • 处理访问者以HTML表格(例如搜索表格)进入的自由形式数据。
  • 通过将查询参数添加到基本URL。
  • 构建通话到用于进一步请求内部服务的API网关。

一个URL具有以下结构:


通常,您无需编码整个URL。当然,在某些情况下,“路径”部分可能包含来自用户上的文件的空格 - 甚至还有诸如主机名的Punycode之类的东西。但是,在大多数情况下,您可以控制主机和路径部分,这意味着您只需要编码代表变量数据(即查询字符串中的参数值)的URL的各个部分。

一个。

当您确实需要编码整个URL时,一个特定的实例是在其他URL的查询字符串中作为参数传递时。

在Java中实施安全的URL编码和解码

要更好地了解Java中的URL编码和解码,请查看Java应用程序中常用的一对类,用于编码和解码查询字符串参数。

如何在Java中编码URL

要在Java应用程序中将百分比编码应用于查询字符串参数的值,您通常使用java.net.URLEncoder类及其encode()方法。

这正是encode()方法所做的:

  • 它可以确保所有字母数字字符,例如azzZ takode11,0 tak tak of 9,和特殊字符.-**_-保持完整。
  • 它将太空字符转换为加号sign +
  • 所有其他字符均为百分比编码。

创建了此方法是为了通过将其转换为application/x-www-form-urlencoded Mime格式来制备从HTML表单中进行提交的数据,该格式用于编码URL查询参数值。

encode()方法的三个过载:

1.**encode(String s, String enc)**允许您明确将编码方案设置为字符串(建议使用UTF-8)。您可以使用此过载,但请注意,它会抛出检查的UnsupportedEncodingException,这意味着您的代码需要使用@throws声明或try/catch块来处理它。同样重要的是要注意,使用字符串文字具有引入错别字的风险:

 String url;
  try {
      url = "https://example.com/search?q=" +
            URLEncoder.encode(parameterValue, "UTF-8");
  } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e);
  }

2.**encode(String s, Charset charset)**自Java 10起就可以使用,并且是迄今为止最好的过载。您对UTF-8(StandardCharsets.UTF_8)使用常数定义,该定义消除了指定编码的错别字的风险,并且不会抛出任何检查过的异常。这意味着您无需处理它们即可编译您的代码:

String url = "https://example.com/search?q=" +
              URLEncoder.encode(parameterValue, StandardCharsets.UTF_8);

3.**encode(String s)**是最古老的过载,并在OpenJDK 17中标记为已弃用。不能保证为UTF-8。

URLEncoder.encode()有一个怪异,因为它可以将一个空间解码为加号而不是%20,这可能是由于遵循了older standard中查询字符串的描述。因此,开发人员有时会修改encode()的输出,以用%20替换加号以表示空间:

return URLEncoder.encode(parameter, StandardCharsets.UTF_8).replaceAll("\\+", "%20");

例如,说您需要使用GitHub REST API search for repositories。 GitHub具有广泛的搜索预选赛,可以通过语言,存储库大小和可见性来筛选搜索结果。例如,对"user:defunkt forks:>100"的搜索将返回具有一百个或更多叉的用户Defunkt的所有存储库。您可以在API调用中使用所有这些过滤器,但是您需要将它们包装在q查询参数中:

String searchQuery = "language:Java stars:100..1000 pushed:>2018-01-01 is:public";
String url = "https://api.github.com/search/repositories?q=" +
           URLEncoder.encode(searchQuery, StandardCharsets.UTF_8) +
           "&per_page=10&sort=starsℴ=desc";
HttpResponse response = sendGetRequest(url);

在此代码中,searchQuery拥有一组搜索预选赛,可帮助您找到自2018年以来更新100至1,000星的所有公共Java存储库。此示例中的值是硬编码的,但也可以来自读取文件,数据库,或通过Web或移动应用程序进行直接输入。

构造url时,此代码会加入三个字符串:

  1. API的基本URL,查询字符串定界符?q参数及其分配定界符=: "https://api.github.com/search/repositories?q="
  2. searchQuery是参数q的值,使用URLEncode()进行了百分比。
  3. 其余的查询参数不需要编码,因为它们是硬编码,并且不包含任何非法字符。

执行此代码时,结果URL为https://api.github.com/search/repositories?q=language%3AJava+stars%3A100..1000+pushed%3A%3E2018-01-01+is%3Apublic&per_page=10&sort=starsℴ=descq的值编码为la nguage%3AJava+stars%3A100..1000+pushed%3A%3E2018-01-01+is%3Apublic,在那里:%3A替换,空格被+替换,而>%3E替换。

如何在Java中解码URL

明确解码URL查询参数的发生频率较小,因为许多框架(包括Spring Boot)自动处理解码。

如果您不依赖框架,则该过程应取决于您下一步要做的事情。

的机会是,您正在收到URL来决定执行哪些操作,例如查询数据或将请求重新路由到其他服务。如果是这样,您的处理逻辑可能涉及分别分析每个查询参数。在这种情况下,您可能需要从分析URL,提取查询字符串并单独解码参数值开始。

用于解码,java.net.URLDecoder.decode()可用于解码百分比编码的字符:

String encodedUrl = "https://www.google.com/search?q=it%27s+my+party&newwindow=1&sxsrf=APwXEdeEqrxGIrZCgLpZFvGUSzgPweokog%3A1682563238731";
URI uri = URI.create(encodedUrl);
List> queryParamsAndValues = Arrays.stream(uri.getRawQuery().split("&"))
 .map(param -> Map.entry(param.split("=")[0], URLDecoder.decode(param.split("=")[1], StandardCharsets.UTF\_8)))
 .toList();

在这里,encodedUrl包含一个Google搜索URL,其查询参数由浏览器编码为百分比。该代码创建了一个新的uri类型URI的对象,以提取整个查询字符串。该对象提供了一种称为getRawQuery()的方法,该方法仅返回具有所有参数值的查询字符串:

q=it%27s+my+party&newwindow=1&sxsrf=APwXEdeEqrxGIrZCgLpZFvGUSzgPweokog%3A1682563238731

然后,代码通过&定界符分配原始查询,从而产生了单个参数/值对的数组。每个对都会转换,因此在值解码时剩下的参数如下。最后,将所有转换的对收集到列表中:

q -> it's my party
newwindow -> 1
sxsrf -> APwXEdeEaxbGIrZCzLpZFvGUSzgPweokog:1682563238731

将所有参数彼此分开但映射到它们各自的值后,您就可以应用验证它们所需的任何逻辑并定义应用程序的下一步。

Java URL处理的最佳实践

在使用Java的URL合作时,有几种最佳做法要牢记确保正确处理和避免潜在问题。

不要跳过URL编码

如果您跳过编码URL,请期待出乎意料的 - 无论是运行时异常还是您要尝试到的服务器的混乱响应。

例如,另一个查看我们的github api调用方案,看看如果不编码搜索参数会发生什么:

String searchQuery = "language:Java stars:100..1000 pushed:>2018-01-01 is:public";
String url = "https://api.github.com/search/repositories?q=" +
           searchQuery +
           "&per_page=10&sort=starsℴ=desc";
HttpResponse response = sendGetRequest(url);

重要的是要注意,在此代码示例中,sendGetRequest(url)包装使用Java 11的请求构建器API构建HTTP请求:

String auth = getAuthToken();
HttpRequest request = HttpRequest.newBuilder()
       .uri(new URI(url))
       .version(HttpClient.Version.HTTP_2)
       .header("Content-Type", "application/json")
       .header("Authorization", auth)
       .timeout(Duration.of(30, SECONDS))
       .GET()
       .build();

HttpClient client = HttpClient.newHttpClient();
return client.send(request, HttpResponse.BodyHandlers.ofString());

如果您执行此代码而不编码searchQuery,则它将在运行时失败,因为URI构造函数无法从包含非编码空间的字符串中创建URI对象:

java.net.URISyntaxException: Illegal character in query at index 58: https://api.github.com/search/repositories?q=language:Java stars:100..1000 pushed:>2018-01-01 is:public&per_page=10&sort=starsℴ=desc
   at java.base/java.net.URI$Parser.fail(URI.java:2974)
   at java.base/java.net.URI$Parser.checkChars(URI.java:3145)
   at java.base/java.net.URI$Parser.parseHierarchical(URI.java:3233)
   at java.base/java.net.URI$Parser.parse(URI.java:3175)
   at java.base/java.net.URI.(URI.java:623)
 at org.example.CallGitHubAPI.sendGetRequest(CallGitHubAPI.java:67)

但是,如果您固执并且想发送此请求怎么办?您可以尝试使用URL对象的Scanner API(与URI相对),并使用它来读取输入流:

String searchQuery = "language:Java stars:100..1000 pushed:>2018-01-01 is:public";
String url = "https://api.github.com/search/repositories?q=" +
           searchQuery +
           "&per_page=10&sort=starsℴ=desc";

URL urlFromNonEncodedString;
Scanner inputStream = null;
try {
  urlFromNonEncodedString = new URL(url);
  inputStream = new Scanner(urlFromNonEncodedString.openConnection().getInputStream());
  System.out.println(inputStream.useDelimiter("\\A").next());
} catch (IOException e) {
  throw new RuntimeException(e);
}
finally {
  if (inputStream != null) inputStream.close();
}

这实际上有效。 API请求转移到其目的地,但它返回错误400 Bad Request,指示可疑的客户端错误,例如畸形请求语法:

Exception in thread "main" java.lang.RuntimeException: java.io.IOException: Server returned HTTP response code: 400 for URL: https://api.github.com/search/repositories?q=language:Java stars:100..1000 pushed:>2018-01-01

rotem栏做得很好,表明如何使用REST在服务间通信中篡改非编码URL。

Rotem描述的场景之一是在您拥有以下服务的情况下进行的URL篡改的可能性:

  1. 从客户端传来的URL中获取参数。
  2. 创建一个新的URL来调用其他服务。

这是一个代码示例,重现了这种情况:

is:public&per_page=10&sort=starsℴ=desc
   at org.example.CallGitHubAPI.callWithoutEncoding(CallGitHubAPI.java:65)
   at org.example.CallGitHubAPI.callGitHubAPI(CallGitHubAPI.java:37)
   at org.example.Main.main(Main.java:16)
Caused by: java.io.IOException: Server returned HTTP response code: 400 for URL: https://api.github.com/search/repositories?q=language:Java stars:100..1000 pushed:>2018-01-01 is:public&per_page=10&sort=starsℴ=desc
   at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1997)
   at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1589)
   at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:224)
   at org.example.CallGitHubAPI.callWithoutEncoding(CallGitHubAPI.java:62)

您可以看到,给定一个非平凡的值,例如q参数中使用的值,跳过URL编码将在一个或另一个步骤中回来困扰您。即使您很幸运能够真正从服务器获得好的响应,也无法确定它包含了您的预期,或者您只是不小心利用了服务器漏洞。

谈到漏洞,而不是编码URL参数很容易引起它们 - 我们在文章"Developers, Please encode your URLs"中进行了进一步讨论。

private static URI forwardRequestToAnotherService(String key, String user) {
    if (!validateUser(user)) return null;
    String newUrl = new StringBuilder()
            .append("https://my.internal.api.com/info?key=")
            .append(key)
            .append("&user=")
            .append(user)
            .toString();
    return URI.create(newUrl);
}

该服务采用两个参数keyuser,并且在验证用户方面做得很好。但是,key尚未验证,只是像创建新URL时一样通过。此新URL中的查询参数均未编码。

此服务收到合法输入时,它会按预期创建一个新的URL:

forwardRequestToAnotherService("55s72502010a", "legituser")
// Output: https://my.internal.api.com/info?key=55s72502010a&user=legituser

但是,如果恶意演员提交了带有篡改的key参数的URL:

forwardRequestToAnotherService("55s72502010a&user=admin#", "legituser")
// Output: https://my.internal.api.com/info?key=55s72502010a&user=admin#&user=legituser

这就是发生的事情:

  1. key值从客户端读取的55s72502010a&user=admin#传递。该服务在不编码的情况下附加了URL的键。
  2. 服务像往常一样验证user参数,而legituser仍然被认为是合法的,因为它是合法的。
  3. 由于原始的key参数包括一个&字符,该字符充当URL查询字符串中参数的分离器,因此结果URL接收两个参数key=55s72502010auser=admin,这是client submitted键的两个子字符串。如您所见,即使该服务已验证了用户legituser,所得URL也模仿了具有潜在提升权限的其他用户,并且该用户绕过了验证。
  4. 因为原始的key参数以#字符结尾,该字符是URL中的片段分离器,因此原始客户端提取的URL的&user=legituser部分被推到了片段部分。
  5. >

因此,您获得了代表未相关且未经验证的用户的URL,该URL有资格作为特权升级攻击。

那么您如何防御这次攻击?一种方法是验证key值,而验证user。但是,您可能需要将该验证委托给请求链下的其他服务。如果是这样,更容易的方法是在构造新URL时简单地编码参数值:

private static URI forwardRequestToAnotherService(String key, String user) {
    if (!validateUser(user)) return null;
    String newUrl = new StringBuilder()
            .append("https://my.internal.api.com/info?key=")
            .append(URLEncoder.encode(key, StandardCharsets.UTF_8))
            .append("&user=")
            .append(URLEncoder.encode(user, StandardCharsets.UTF_8))
            .toString();
    return URI.create(newUrl);
}

进行此修改,请查看如果有人试图提交恶意key值:

forwardRequestToAnotherService("55s72502010a&user=admin#", "legituser")
// Output: https://my.internal.api.com/info?key=55s72502010a%26user%3Dadmin%23&user=legituser

编码的key值(key=55s72502010a%26user%3Dadmin%23)阻止了尝试的特权升级,因为&被称为%26#作为%23。最终的URL使用正确的用户名进入了下一个服务。下一个服务可能会在尝试后返回错误,并且无法理解篡改的key参数,但是错误响应随时击败了攻击。

使用标准库进行编码和解码URL

特别是在Java中,出于编码百分比的查询参数的目的,您可能大部分时间都使用URLEncoder.encode()URLDecoder.decode(),因为这些都是可靠的工具。它们可以在开箱即用的任何Java项目中使用。

另一个选择是使用Open Web Application Security Project (OWASP) Java Encoder库。尽管其核心目的是针对各种上下文的输入验证,但it also contains Encode.forUriComponent()用于编码URL参数值和REST路径的方法。如果您的Java应用程序需要安全地显示客户端提取的URL及其零件,则此库是一个不错的选择。

也就是说,根据您使用的框架,您可能根本不需要明确解码,或者可能必须以不同的方式进行解码:

  • 在春季或春季启动中,@RequestParam注释应用于控制器参数可以自动解码该参数的值。
  • 在GWT应用程序中,您可能使用GWT自己的[com.google.gwt.http.client.URL](https://www.gwtproject.org/javadoc/latest/com/google/gwt/http/client/URL.html)类中定义的方法。
  • Android具有单独的URL构建器API,其中包括查询参数编码。

如果您使用第三方库来编码和解码URL,则可以扫描它们以了解已知漏洞,如果找到漏洞,请在安全修复程序可用后立即更新库。

在您的应用中与它们互动之前,请验证和消毒URL

URL编码只是方程的一部分。如果您的Web应用程序通过URL参数从用户获取数据,然后将数据传递回Web应用程序,则应确保用户数据在将其放回浏览器上下文之前是无害的。

这意味着您应该validate user data,包括通过服务器端上的URL到达的用户数据,涉及用户数据上的语法和语义,并使用白名单,因为黑名单更容易出现错误和遗漏。

此外,如果您的应用程序允许用户提交HTML代码,则应实现HTML sanitization。 Java中通常用于此目的的一个库是OWASP Java HTML Sanitizer

使用Intellij Idea的SNYK安全扩展程序查找不安全的URL

url编码和解码应成为更广泛的安全实践的一部分,例如encoding and escaping datainput validation。编写安全代码至关重要,并且在开发周期中尽早揭示了安全漏洞,使修复它们的成本及其安全事件的影响。

作为在IntelliJ IDEA工作的Java开发人员,您可能会从安装Snyk Security扩展程序中受益。

SNYK安全发现问题,在代码编辑器中突出显示它们,并帮助您在以下内容中修复已知的安全漏洞:

  • 您自己的代码
  • 您要进入项目的直接和传递开源依赖关系
  • 您的Docker图像
  • 您的基础架构 - AS代码模板

SNYK安全性扩展可帮助您确定来自URL参数未经启发的输入等问题,可能导致跨站点脚本(XSS),命令注入,服务器端请求伪造(SSRF)或打开重定向漏洞。对于每个检测到的漏洞,它显示了各种开源Java项目过去如何修复了类似的问题:


这是Snyk Security在您的Java代码上运行的the full list of security inspections

结论

未能编码URL参数似乎是一个小遗漏,但是您从本文中看到的,它可能会对Java应用程序的可靠性和安全性产生严重影响。编码百分比编码的URL参数有助于为合法用户提供预期的结果,并阻止恶意参与者绕过访问控件和执行攻击。

虽然编码和解码只是更广泛的最佳安全实践的一部分,但坚持使用稳定的API和智能开发人员工具(例如Snyk Security extension for IntelliJ IDEA)可以帮助您正确地获取它,始终如一地运送安全的代码,并避免花费痛苦的时间解决和关注时间。付出生产安全事件。