Skip to content

Commit 62a6bf7

Browse files
committed
Add better header and value validation
We now validate that header names and values are valid, and more consistently trim and validate values. This is improved over String.trim as it doesn't trim off \r or \n too, which would be too much lenience.
1 parent ddb505e commit 62a6bf7

6 files changed

Lines changed: 274 additions & 85 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.http.api;
7+
8+
/**
9+
* HTTP header utilities.
10+
*/
11+
public final class HeaderUtils {
12+
// Header name lookup table: 0=invalid, 1=valid lower, 2=valid upper.
13+
private static final byte[] HEADER_NAME_TABLE = new byte[128];
14+
15+
// Header value lookup table: true = allowed character
16+
private static final boolean[] VALID_VALUE_CHAR = new boolean[256];
17+
18+
static {
19+
for (char c = 'a'; c <= 'z'; c++) {
20+
HEADER_NAME_TABLE[c] = 1;
21+
}
22+
for (char c = 'A'; c <= 'Z'; c++) {
23+
HEADER_NAME_TABLE[c] = 2;
24+
}
25+
String validChars = "!#$%&'*+-.0123456789^_`|~";
26+
for (int i = 0; i < validChars.length(); i++) {
27+
HEADER_NAME_TABLE[validChars.charAt(i)] = 1;
28+
}
29+
30+
// Valid value chars per RFC 7230 field-content: SP, HTAB, VCHAR, obs-text
31+
VALID_VALUE_CHAR[' '] = true;
32+
VALID_VALUE_CHAR['\t'] = true;
33+
for (int c = 0x21; c <= 0x7E; c++) {
34+
VALID_VALUE_CHAR[c] = true;
35+
}
36+
for (int c = 0x80; c <= 0xFF; c++) {
37+
VALID_VALUE_CHAR[c] = true;
38+
}
39+
}
40+
41+
private HeaderUtils() {}
42+
43+
/**
44+
* Normalizes an HTTP header name by trimming OWS (SP/HTAB), converting ASCII uppercase to lowercase, and
45+
* validating per RFC 7230 token rules.
46+
*
47+
* @param name the header name to normalize
48+
* @return the normalized header name, or the original instance if already normalized
49+
* @throws IllegalArgumentException if the name is empty, whitespace-only, or contains invalid characters
50+
*/
51+
static String normalizeName(String name) {
52+
// Simulate String.trim(), but we only want to trim leading and trailing ' ' and '\t'.
53+
int len = name.length();
54+
int end = len - 1;
55+
int start = trimStart(name, end);
56+
end = trimEnd(name, start);
57+
if (start > end) {
58+
throw new IllegalArgumentException("Header name is empty or whitespace-only");
59+
}
60+
61+
// Ensure each character is valid and see if any are uppercase.
62+
boolean needsLower = false;
63+
for (int i = start; i <= end; i++) {
64+
char c = name.charAt(i);
65+
if (c >= 128) {
66+
throw invalidHeaderNameChar(name);
67+
}
68+
byte b = HEADER_NAME_TABLE[c];
69+
if (b == 0) {
70+
throw invalidHeaderNameChar(name);
71+
} else if (b == 2) {
72+
needsLower = true;
73+
}
74+
}
75+
76+
// No lower casing needed means trim or return as-is.
77+
if (!needsLower) {
78+
return start == 0 && end == len - 1
79+
? name
80+
: name.substring(start, end + 1);
81+
}
82+
83+
// needs lowercasing and possible trimming.
84+
char[] chars = new char[end - start + 1];
85+
for (int src = start, dst = 0; src <= end; src++, dst++) {
86+
char c = name.charAt(src);
87+
chars[dst] = HEADER_NAME_TABLE[c] == 2 ? (char) (c + 32) : c;
88+
}
89+
90+
return new String(chars);
91+
}
92+
93+
/**
94+
* Normalizes an HTTP header value by trimming leading/trailing OWS (SP/HTAB) and validating per RFC 7230
95+
* field-content rules.
96+
*
97+
* <p>Interior SP/HTAB are allowed. CR, LF, and other control characters are rejected. Empty values are permitted
98+
* per RFC 7230.
99+
*
100+
* @param value the header value to normalize
101+
* @return the normalized header value, or the original instance if already normalized
102+
* @throws IllegalArgumentException if the value contains invalid characters
103+
*/
104+
public static String normalizeValue(String value) {
105+
// Simulate String.trim(), but we only want to trim leading and trailing ' ' and '\t'.
106+
int len = value.length();
107+
int end = len - 1;
108+
int start = trimStart(value, end);
109+
end = trimEnd(value, start);
110+
if (start > end) {
111+
return "";
112+
}
113+
114+
for (int i = start; i <= end; i++) {
115+
char c = value.charAt(i);
116+
if (c > 255 || !VALID_VALUE_CHAR[c]) {
117+
throw invalidHeaderValueChar(value);
118+
}
119+
}
120+
121+
return (start == 0 && end == len - 1)
122+
? value
123+
: value.substring(start, end + 1);
124+
}
125+
126+
private static int trimStart(String s, int end) {
127+
for (int start = 0; start <= end; start++) {
128+
char c = s.charAt(start);
129+
if (c != ' ' && c != '\t') {
130+
return start;
131+
}
132+
}
133+
return end + 1;
134+
}
135+
136+
private static int trimEnd(String s, int start) {
137+
for (int end = s.length() - 1; end >= start; end--) {
138+
char c = s.charAt(end);
139+
if (c != ' ' && c != '\t') {
140+
return end;
141+
}
142+
}
143+
return start - 1;
144+
}
145+
146+
private static IllegalArgumentException invalidHeaderNameChar(String value) {
147+
return new IllegalArgumentException("Invalid header name: " + value);
148+
}
149+
150+
private static IllegalArgumentException invalidHeaderValueChar(String value) {
151+
return new IllegalArgumentException("Invalid header value: " + value);
152+
}
153+
}

http/http-api/src/main/java/software/amazon/smithy/java/http/api/HttpHeaders.java

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
* <p>Implementations must always normalize header names to lowercase.
1515
*/
1616
public interface HttpHeaders extends Iterable<Map.Entry<String, List<String>>> {
17-
1817
/**
1918
* Create an immutable HttpHeaders.
2019
*
@@ -122,64 +121,4 @@ default ModifiableHttpHeaders toModifiable() {
122121
default HttpHeaders toUnmodifiable() {
123122
return SimpleUnmodifiableHttpHeaders.of(this);
124123
}
125-
126-
/**
127-
* Normalizes an HTTP header name by trimming whitespace and converting ASCII uppercase to lowercase.
128-
*
129-
* <p>Trimming behavior matches {@link String#trim()}, removing characters {@code <= 'u0020'}.
130-
* Only ASCII uppercase letters (A-Z) are lowercased; non-ASCII characters pass through unchanged,
131-
* which is correct per RFC 7230 (HTTP/1.1) and RFC 9110 (HTTP semantics) since header field names
132-
* are defined as ASCII tokens.
133-
*
134-
* @param name the header name to normalize
135-
* @return the normalized header name, or the original instance if already normalized
136-
*/
137-
static String normalizeHeaderName(String name) {
138-
int len = name.length();
139-
int start = 0;
140-
int end = len - 1;
141-
boolean needsWork = false;
142-
143-
// Detect leading whitespace to trim if needed
144-
while (start <= end && name.charAt(start) <= ' ') {
145-
needsWork = true;
146-
start++;
147-
}
148-
149-
// Detect trailing whitespace to trim if needed
150-
while (end >= start && name.charAt(end) <= ' ') {
151-
needsWork = true;
152-
end--;
153-
}
154-
155-
// All whitespace
156-
if (start > end) {
157-
return "";
158-
}
159-
160-
// Scan for ASCII uppercase
161-
for (int i = start; i <= end; i++) {
162-
char c = name.charAt(i);
163-
if (c >= 'A' && c <= 'Z') {
164-
needsWork = true;
165-
break;
166-
}
167-
}
168-
169-
if (!needsWork) {
170-
return name;
171-
}
172-
173-
int outLen = end - start + 1;
174-
char[] chars = new char[outLen];
175-
for (int src = start, dst = 0; src <= end; src++, dst++) {
176-
char c = name.charAt(src);
177-
if (c >= 'A' && c <= 'Z') {
178-
c = (char) (c + 32);
179-
}
180-
chars[dst] = c;
181-
}
182-
183-
return new String(chars);
184-
}
185124
}

http/http-api/src/main/java/software/amazon/smithy/java/http/api/ModifiableHttpHeaders.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ default void addHeaders(Map<String, List<String>> headers) {
5555
/**
5656
* Sets a header to the given value, overwriting old values if present.
5757
*
58-
* <p>Any previously set values for this header are replaced as if {@link #removeHeader(String) and
59-
* {@link #addHeader(String, String)}} were called in sequence. To add a new value to a
58+
* <p>Any previously set values for this header are replaced as if {@link #removeHeader(String)} and
59+
* {@link #addHeader(String, String)} were called in sequence. To add a new value to a
6060
* list of values, use {@link #addHeader(String, String)}.
6161
*
6262
* @param name Case-insensitive name of the header to set.
@@ -70,8 +70,8 @@ default void setHeader(String name, String value) {
7070
/**
7171
* Sets a header to the given value, overwriting old values if present.
7272
*
73-
* <p>Any previously set values for this header are replaced as if {@link #removeHeader(String) and
74-
* {@link #addHeader(String, String)}} were called in sequence. To add new values to a
73+
* <p>Any previously set values for this header are replaced as if {@link #removeHeader(String)} and
74+
* {@link #addHeader(String, String)} were called in sequence. To add new values to a
7575
* list of values, use {@link #addHeader(String, List)}.
7676
*
7777
* @param name Case-insensitive name of the header to set.

http/http-api/src/main/java/software/amazon/smithy/java/http/api/SimpleModifiableHttpHeaders.java

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,21 @@ static ModifiableHttpHeaders of(HttpHeaders headers) {
3737

3838
@Override
3939
public void addHeader(String name, String value) {
40-
getOrCreateValues(name).add(trim(value));
40+
getOrCreateValues(name).add(HeaderUtils.normalizeValue(value));
4141
}
4242

4343
@Override
4444
public void addHeader(String name, List<String> values) {
4545
if (!values.isEmpty()) {
4646
var line = getOrCreateValues(name);
4747
for (var v : values) {
48-
line.add(trim(v));
48+
line.add(HeaderUtils.normalizeValue(v));
4949
}
5050
}
5151
}
5252

5353
private List<String> getOrCreateValues(String name) {
54-
return getOrCreateValuesUnsafe(HttpHeaders.normalizeHeaderName(name));
54+
return getOrCreateValuesUnsafe(HeaderUtils.normalizeName(name));
5555
}
5656

5757
private List<String> getOrCreateValuesUnsafe(String key) {
@@ -65,7 +65,7 @@ private List<String> getOrCreateValuesUnsafe(String key) {
6565

6666
@Override
6767
public void setHeader(String name, String value) {
68-
var key = HttpHeaders.normalizeHeaderName(name);
68+
var key = HeaderUtils.normalizeName(name);
6969
var list = headers.get(key);
7070
if (list == null) {
7171
list = new ArrayList<>(1);
@@ -74,25 +74,21 @@ public void setHeader(String name, String value) {
7474
list.clear();
7575
}
7676

77-
list.add(trim(value));
78-
}
79-
80-
private String trim(String value) {
81-
return value.trim();
77+
list.add(HeaderUtils.normalizeValue(value));
8278
}
8379

8480
@Override
8581
public void setHeader(String name, List<String> values) {
8682
List<String> copy = new ArrayList<>(values.size());
8783
for (var v : values) {
88-
copy.add(trim(v));
84+
copy.add(HeaderUtils.normalizeValue(v));
8985
}
90-
headers.put(HttpHeaders.normalizeHeaderName(name), copy);
86+
headers.put(HeaderUtils.normalizeName(name), copy);
9187
}
9288

9389
@Override
9490
public void removeHeader(String name) {
95-
headers.remove(name.toLowerCase(Locale.ENGLISH));
91+
headers.remove(HeaderUtils.normalizeName(name));
9692
}
9793

9894
@Override
@@ -102,7 +98,7 @@ public void clear() {
10298

10399
@Override
104100
public List<String> allValues(String name) {
105-
return headers.getOrDefault(name.toLowerCase(Locale.ENGLISH), Collections.emptyList());
101+
return headers.getOrDefault(name.toLowerCase(Locale.ROOT), Collections.emptyList());
106102
}
107103

108104
@Override
@@ -135,18 +131,19 @@ public void setHeaders(HttpHeaders headers) {
135131

136132
@Override
137133
public List<String> setHeaderIfAbsent(String name, List<String> values) {
138-
return headers.computeIfAbsent(HttpHeaders.normalizeHeaderName(name), n -> {
134+
return headers.computeIfAbsent(HeaderUtils.normalizeName(name), n -> {
139135
var trimmed = new ArrayList<String>(values.size());
140136
for (var v : values) {
141-
trimmed.add(v.trim());
137+
trimmed.add(HeaderUtils.normalizeValue(v));
142138
}
143139
return trimmed;
144140
});
145141
}
146142

147143
@Override
148144
public List<String> setHeaderIfAbsent(String name, String value) {
149-
return headers.computeIfAbsent(HttpHeaders.normalizeHeaderName(name), n -> List.of(value.trim()));
145+
return headers.computeIfAbsent(HeaderUtils.normalizeName(name),
146+
n -> List.of(HeaderUtils.normalizeValue(value)));
150147
}
151148

152149
// Set header using a pre-formatted keys and already trimmed values.

http/http-api/src/main/java/software/amazon/smithy/java/http/api/SimpleUnmodifiableHttpHeaders.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ final class SimpleUnmodifiableHttpHeaders implements HttpHeaders {
4141
// Single pass to normalize, trim, and make immutable in one go
4242
Map<String, List<String>> result = HashMap.newHashMap(input.size());
4343
for (var entry : input.entrySet()) {
44-
var key = HttpHeaders.normalizeHeaderName(entry.getKey());
44+
var key = HeaderUtils.normalizeName(entry.getKey());
4545
var values = entry.getValue();
4646
var existing = result.get(key);
4747
if (existing == null) {
@@ -64,14 +64,13 @@ static HttpHeaders of(HttpHeaders headers) {
6464

6565
private static void copyAndTrimValuesInto(List<String> source, List<String> dest) {
6666
for (String s : source) {
67-
dest.add(s.trim());
67+
dest.add(HeaderUtils.normalizeValue(s));
6868
}
6969
}
7070

7171
@Override
7272
public List<String> allValues(String name) {
73-
var values = headers.get(name.toLowerCase(Locale.ENGLISH));
74-
return values != null ? values : List.of();
73+
return headers.getOrDefault(name.toLowerCase(Locale.ROOT), List.of());
7574
}
7675

7776
@Override

0 commit comments

Comments
 (0)