Skip to content

Commit 5d77e7e

Browse files
authored
Add a HTML sanitizer for translated message resources
Closes #37428 Signed-off-by: Alexander Schwartz <[email protected]>
1 parent 21d5311 commit 5d77e7e

File tree

48 files changed

+343
-105
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+343
-105
lines changed

js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ webauthn-help-text=Use your Passkey to sign in.
185185
webauthn-passwordless-display-name=Passkey
186186
webauthn-passwordless-help-text=Use your Passkey for passwordless sign in.
187187
passwordless=Passwordless
188-
error-invalid-multivalued-size=Attribute {{0}} must have at least {{1}} and at most {{2}} value(s).
188+
error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most {2} {2,choice,0#values|1#value|1<values}.
189189
recovery-authn-code=My recovery authentication codes
190190
recovery-authn-codes-display-name=Recovery authentication codes
191191
recovery-authn-codes-help-text=These codes can be used to regain your access in case your other 2FA means are not available.

js/apps/admin-ui/maven-resources-community/theme/keycloak.v2/admin/messages/messages_zh_CN.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1252,7 +1252,7 @@ onDragCancel=已取消拖动。列表未更改。
12521252
removeUser=移除用户
12531253
ownerManagedAccess=启用用户管理访问
12541254
userModelAttributeNameHelp=从 LDAP 导入用户时要添加的模型属性的名称
1255-
templateHelp=用于格式化要导入的用户名的模板。替换包含在 ${} 中。例如:'${ALIAS}.${CLAIM.sub}'。ALIAS 是供应商别名。CLAIM.<NAME > 引用 ID 或访问令牌声明。可以通过将 |uppercase 或 |lowercase 附加到替换值来将替换转换为大写或小写,例如“${CLAIM.sub | lowercase}”。
1255+
templateHelp=用于格式化要导入的用户名的模板。替换包含在 ${} 中。例如:'${ALIAS}.${CLAIM.sub}'。ALIAS 是供应商别名。CLAIM.<NAME> 引用 ID 或访问令牌声明。可以通过将 |uppercase 或 |lowercase 附加到替换值来将替换转换为大写或小写,例如“${CLAIM.sub | lowercase}”。
12561256
permissions=权限
12571257
emptyExecutionInstructions=您可以通过添加子流程或执行器来开始定义此流程
12581258
offlineSessionSettings=离线会话设置

js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3133,7 +3133,7 @@ bruteForceMode.PermanentLockout=Lockout permanently
31333133
bruteForceMode.TemporaryLockout=Lockout temporarily
31343134
bruteForceMode.PermanentAfterTemporaryLockout=Lockout permanently after temporary lockout
31353135
bruteForceMode=Brute Force Mode
3136-
error-invalid-multivalued-size=Attribute {{0}} must have at least {{1}} and at most {{2}} value(s).
3136+
error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most {2} {2,choice,0#values|1#value|1<values}.
31373137
multivalued=Multivalued
31383138
multivaluedHelp=If this attribute supports multiple values. This setting is an indicator and does not enable any validation.
31393139
to the attribute. For that, make sure to use any of the built-in validators to properly validate the size and the values.

misc/theme-verifier/pom.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@
7272
<version>2.2</version>
7373
<scope>test</scope>
7474
</dependency>
75+
<dependency>
76+
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
77+
<artifactId>owasp-java-html-sanitizer</artifactId>
78+
<version>20240325.1</version>
79+
</dependency>
80+
<dependency>
81+
<groupId>org.apache.commons</groupId>
82+
<artifactId>commons-text</artifactId>
83+
<version>1.13.0</version>
84+
<scope>compile</scope>
85+
</dependency>
7586
</dependencies>
7687

7788

misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,22 @@
1717
package org.keycloak.themeverifier;
1818

1919
import org.apache.maven.plugin.MojoExecutionException;
20+
import org.owasp.html.PolicyFactory;
2021

2122
import java.io.BufferedReader;
2223
import java.io.File;
24+
import java.io.FileInputStream;
2325
import java.io.IOException;
2426
import java.io.StringReader;
2527
import java.nio.file.Files;
2628
import java.util.ArrayList;
2729
import java.util.HashSet;
2830
import java.util.List;
31+
import java.util.MissingResourceException;
32+
import java.util.Objects;
33+
import java.util.PropertyResourceBundle;
34+
import java.util.regex.Matcher;
35+
import java.util.regex.Pattern;
2936

3037
public class VerifyMessageProperties {
3138

@@ -41,12 +48,129 @@ public List<String> verify() throws MojoExecutionException {
4148
try {
4249
String contents = Files.readString(file.toPath());
4350
verifyNoDuplicateKeys(contents);
51+
verifySafeHtml();
4452
} catch (IOException e) {
4553
throw new MojoExecutionException("Can not read file " + file, e);
4654
}
4755
return messages;
4856
}
4957

58+
PolicyFactory POLICY_SOME_HTML = new org.owasp.html.HtmlPolicyBuilder()
59+
.allowElements(
60+
"br", "p", "strong", "b"
61+
).toFactory();
62+
63+
PolicyFactory POLICY_NO_HTML = new org.owasp.html.HtmlPolicyBuilder().toFactory();
64+
65+
private void verifySafeHtml() {
66+
PropertyResourceBundle bundle;
67+
try (FileInputStream fis = new FileInputStream(file)) {
68+
bundle = new PropertyResourceBundle(fis);
69+
} catch (IOException e) {
70+
throw new RuntimeException("unable to read file " + file, e);
71+
}
72+
73+
PropertyResourceBundle bundleEnglish;
74+
String englishFile = file.getAbsolutePath().replaceAll("resources-community", "resources")
75+
.replaceAll("_[a-zA-Z-_]*\\.properties", "_en.properties");
76+
try (FileInputStream fis = new FileInputStream(englishFile)) {
77+
bundleEnglish = new PropertyResourceBundle(fis);
78+
} catch (IOException e) {
79+
throw new RuntimeException("unable to read file " + englishFile, e);
80+
}
81+
82+
bundle.getKeys().asIterator().forEachRemaining(key -> {
83+
String value = bundle.getString(key);
84+
value = normalizeValue(key, value);
85+
String englishValue = getEnglishValue(key, bundleEnglish);
86+
englishValue = normalizeValue(key, englishValue);
87+
88+
value = santizeAnchors(key, value, englishValue);
89+
90+
// Only if the English source string contains HTML we also allow HTML in the translation
91+
PolicyFactory policy = containsHtml(englishValue) ? POLICY_SOME_HTML : POLICY_NO_HTML;
92+
String sanitized = policy.sanitize(value);
93+
94+
// Sanitizer will escape HTML entities for quotes and also for numberic tags like '<1>'
95+
sanitized = org.apache.commons.text.StringEscapeUtils.unescapeHtml4(sanitized);
96+
// Sanitizer will add them when there are double curly braces
97+
sanitized = sanitized.replace("<!-- -->", "");
98+
99+
if (!Objects.equals(sanitized, value)) {
100+
101+
// Strip identical characters from the beginning and the end to show where the difference is
102+
int start = 0;
103+
while (start < sanitized.length() && start < value.length() && value.charAt(start) == sanitized.charAt(start)) {
104+
start++;
105+
}
106+
int end = 0;
107+
while (end < sanitized.length() && end < value.length() && value.charAt(value.length() - end - 1) == sanitized.charAt(sanitized.length() - end - 1)) {
108+
end++;
109+
}
110+
111+
messages.add("Illegal HTML in key " + key + " for file " + file + ": '" + value.substring(start, value.length() - end) + "' vs. '" + sanitized.substring(start, sanitized.length() - end) + "'");
112+
}
113+
114+
});
115+
}
116+
117+
private String normalizeValue(String key, String value) {
118+
if (key.equals("templateHelp")) {
119+
// Allow "CLAIM.<NAME>" here
120+
value = value.replaceAll("CLAIM\\.<[A-Z]*>", "");
121+
} else if (key.equals("optimizeLookupHelp")) {
122+
// Allow "<Extensions>" here
123+
value = value.replaceAll("<Extensions>", "");
124+
} else if (key.startsWith("linkExpirationFormatter.timePeriodUnit") || key.equals("error-invalid-multivalued-size")) {
125+
// The problem is the "<" that appears in the choice
126+
value = value.replaceAll("\\{[0-9]+,choice,[^}]*}", "...");
127+
}
128+
129+
// Unescape HTML entities, as we later also unescape HTML entities in the sanitized value
130+
value = org.apache.commons.text.StringEscapeUtils.unescapeHtml4(value);
131+
132+
if (file.getAbsolutePath().contains("email")) {
133+
// TODO: move the RTL information for emails
134+
value = value.replaceAll(Pattern.quote(" style=\"direction: rtl;\""), "");
135+
}
136+
return value;
137+
}
138+
139+
Pattern HTML_TAGS = Pattern.compile("<[a-z]+[^>]*>");
140+
141+
private boolean containsHtml(String englishValue) {
142+
return HTML_TAGS.matcher(englishValue).find();
143+
}
144+
145+
private static final Pattern ANCHOR_PATTERN = Pattern.compile("</?a[^>]*>");
146+
147+
/**
148+
* Allow only those anchor tags from the source key to also appear in the target key.
149+
*/
150+
private String santizeAnchors(String key, String value, String englishValue) {
151+
Matcher matcher = ANCHOR_PATTERN.matcher(value);
152+
Matcher englishMatcher = ANCHOR_PATTERN.matcher(englishValue);
153+
while (matcher.find()) {
154+
if (englishMatcher.find() && Objects.equals(matcher.group(), englishMatcher.group())) {
155+
value = value.replaceFirst(Pattern.quote(englishMatcher.group()), "");
156+
} else {
157+
messages.add("Didn't find anchor tag " + matcher.group() + " in original string");
158+
break;
159+
}
160+
}
161+
return value;
162+
}
163+
164+
private static String getEnglishValue(String key, PropertyResourceBundle bundleEnglish) {
165+
String englishValue;
166+
try {
167+
englishValue = bundleEnglish.getString(key);
168+
} catch (MissingResourceException ex) {
169+
englishValue = "";
170+
}
171+
return englishValue;
172+
}
173+
50174
private void verifyNoDuplicateKeys(String contents) throws IOException {
51175
BufferedReader bufferedReader = new BufferedReader(new StringReader(contents));
52176
String line;

misc/theme-verifier/src/test/java/org/keycloak/themeverifier/VerifyMessagePropertiesTest.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,26 @@ class VerifyMessagePropertiesTest {
2929

3030
@Test
3131
void verifyDuplicateKeysDetected() throws MojoExecutionException {
32-
List<String> verify = getFile("duplicate_keys.properties").verify();
33-
MatcherAssert.assertThat(verify, Matchers.contains(Matchers.containsString("Duplicate keys in file")));
32+
List<String> verify = getFile("duplicateKeys_en.properties").verify();
33+
MatcherAssert.assertThat(verify, Matchers.hasItem(Matchers.containsString("Duplicate keys in file")));
34+
}
35+
36+
@Test
37+
void verifyIllegalHtmlTagDetected() throws MojoExecutionException {
38+
List<String> verify = getFile("illegalHtmlTag_en.properties").verify();
39+
MatcherAssert.assertThat(verify, Matchers.hasItem(Matchers.containsString("Illegal HTML")));
40+
}
41+
42+
@Test
43+
void verifyNoHtmlAllowed() throws MojoExecutionException {
44+
List<String> verify = getFile("noHtml_de.properties").verify();
45+
MatcherAssert.assertThat(verify, Matchers.hasItem(Matchers.containsString("Illegal HTML")));
46+
}
47+
48+
@Test
49+
void verifyNoChangedAnchors() throws MojoExecutionException {
50+
List<String> verify = getFile("changedAnchor_de.properties").verify();
51+
MatcherAssert.assertThat(verify, Matchers.hasItem(Matchers.containsString("Didn't find anchor tag")));
3452
}
3553

3654
private static VerifyMessageProperties getFile(String fixture) {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#
2+
# Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
# and other contributors as indicated by the @author tags.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
key=Some <a href="http://malicious.com">link</a>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#
2+
# Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
# and other contributors as indicated by the @author tags.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
key=Some <a href="http://example.com">link</a>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#
2+
# Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
# and other contributors as indicated by the @author tags.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
key=Some <div>tag</div

0 commit comments

Comments
 (0)