quarkusio/quarkus · error · IllegalArgumentException
invalid escape sequence `%${c}${c2}' at index ${i-2} of: ${s
Error message
invalid escape sequence `%${c}${c2}' at index ${i-2} of: ${s} What it means
After '%' the decoder reads two characters and converts each with decodeHexNibble. If either character is not a valid hex digit (decodeHexNibble returns Character.MAX_VALUE), this IllegalArgumentException reports the invalid escape, its index, and the full input. It enforces strict RFC-compliant percent-decoding.
Source
Thrown at independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/URIDecoder.java:81
for (; i < size; i++) {
char c = s.charAt(i);
if (c == '%') {
if (i == size - 1) {
throw new IllegalArgumentException("unterminated escape"
+ " sequence at end of string: " + s);
}
c = s.charAt(++i);
if (c == '%') {
buf[pos++] = '%'; // "%%" -> "%"
break;
}
if (i >= size - 1) {
throw new IllegalArgumentException("partial escape"
+ " sequence at end of string: " + s);
}
c = decodeHexNibble(c);
final char c2 = decodeHexNibble(s.charAt(++i));
if (c == Character.MAX_VALUE || c2 == Character.MAX_VALUE) {
throw new IllegalArgumentException(
"invalid escape sequence `%" + s.charAt(i - 1)
+ s.charAt(i) + "' at index " + (i - 2)
+ " of: " + s);
}
c = (char) (c * 16 + c2);
// shouldn't check for plus since it would be a double decoding
buf[pos++] = (byte) c;
} else {
buf[pos++] = (byte) (plus && c == '+' ? ' ' : c);
}
}
return new String(buf, 0, pos, StandardCharsets.UTF_8);
}
/**
* Helper to decode half of a hexadecimal number from a string.
*View on GitHub (pinned to e1c734241f)
Solutions
- Percent-encode literal '%' as %25 before decoding
- Fix the escape to use only hex characters [0-9A-Fa-f]
- Validate with regex /^([^%]|%[0-9A-Fa-f]{2})*$/ before calling the decoder
- Catch IllegalArgumentException and return a 400 response with a descriptive message
Example fix
// before
URIDecoder.decodeURIComponent("search=50% off");
// after
URIDecoder.decodeURIComponent("search=50%25 off"); Defensive patterns
Strategy: validation
Validate before calling
private static final Pattern HEXESC = Pattern.compile("^([^%]|%[0-9A-Fa-f]{2})*$");
static boolean strictlyEncoded(String s) { return s != null && HEXESC.matcher(s).matches(); } Type guard
static boolean allEscapesAreHex(String s) {
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '%') {
if (i + 2 >= s.length()) return false;
if (!isHex(s.charAt(i+1)) || !isHex(s.charAt(i+2))) return false;
i += 2;
}
}
return true;
} Try / catch
try {
decoded = URIDecoder.decodeURIComponent(raw);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null && e.getMessage().contains("invalid escape sequence")) {
throw new BadRequestException("Non-hex percent-escape in: " + raw);
} else throw e;
} Prevention
- Encode user text with encodeURIComponent/java.net.URLEncoder before decoding
- Treat any raw '%' in user data as '%25' — sanitize before decode
- Reject at the edge: validate query/path params with a strict regex filter
- Do not pass template strings (e.g. '%{var}') directly to decoders
When it happens
Trigger: Calling decodeURIComponent with an escape whose digits are not hex, e.g. "%GG", "%zz", "%20x" mixed sequences like "%q1" — any of the two characters after '%' being non-hex.
Common situations: Hand-written or machine-mangled URLs with non-hex after '%'; decoding strings that were never URL-encoded (raw '%' used as literal, e.g. "100% off"); template placeholders like '%{var}' passed to the decoder.
Related errors
- unterminated escape sequence at end of string: ${s}
- partial escape sequence at end of string: ${s}
- Specified path can not contain '..' or '%'. Path was
- Specified path is an invalid URI. Path was
- The value of URL was invalid " + baseUrl
AI-assisted analysis of quarkusio/quarkus@e1c734241f (2026-09-05).
Data as JSON: /api/errors/dadcd5a219560672.
Report an issue: GitHub.