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()
方法所做的:
- 它可以确保所有字母数字字符,例如
a
,z
,z
,Z
takode11,0
tak tak of9,
和特殊字符.
,-
,*
,*
和_
-保持完整。 - 它将太空字符转换为加号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
时,此代码会加入三个字符串:
- API的基本URL,查询字符串定界符
?
,q
参数及其分配定界符=: "https://api.github.com/search/repositories?q="
。 -
searchQuery
是参数q
的值,使用URLEncode()
进行了百分比。 - 其余的查询参数不需要编码,因为它们是硬编码,并且不包含任何非法字符。
执行此代码时,结果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ℴ=desc
。 q
的值编码为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篡改的可能性:
- 从客户端传来的URL中获取参数。
- 创建一个新的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);
}
该服务采用两个参数key
和user
,并且在验证用户方面做得很好。但是,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
这就是发生的事情:
-
key
值从客户端读取的55s72502010a&user=admin#
传递。该服务在不编码的情况下附加了URL的键。 - 服务像往常一样验证
user
参数,而legituser
仍然被认为是合法的,因为它是合法的。 - 由于原始的
key
参数包括一个&
字符,该字符充当URL查询字符串中参数的分离器,因此结果URL接收两个参数key=55s72502010a
和user=admin
,这是client submitted键的两个子字符串。如您所见,即使该服务已验证了用户legituser
,所得URL也模仿了具有潜在提升权限的其他用户,并且该用户绕过了验证。 - 因为原始的
key
参数以#
字符结尾,该字符是URL中的片段分离器,因此原始客户端提取的URL的&user=legituser
部分被推到了片段部分。 >
因此,您获得了代表未相关且未经验证的用户的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 data和input 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)可以帮助您正确地获取它,始终如一地运送安全的代码,并避免花费痛苦的时间解决和关注时间。付出生产安全事件。