Skip to content

Commit 8cc1a7e

Browse files
committed
resteasy: recognize RFC 5987 filename* as a file indicator too
- hasFilenameParam() detects the extended filename* Content-Disposition parameter as well as plain filename, closing the same cap-padding bypass via the extended form (Codex P1 follow-up on PR #12085) - add regression test for a part carrying only filename* (no plain filename)
1 parent 310e07e commit 8cc1a7e

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

dd-java-agent/instrumentation/resteasy/resteasy-appsec-3.0/src/main/java/datadog/trace/instrumentation/resteasy/MultipartHelper.java

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,10 @@ private static String contentTypeOf(Map<String, List<String>> headers) {
123123

124124
// A part with a filename attribute (present, even if empty) is a file upload, not a form field,
125125
// regardless of its declared content-type: a file can be declared text/plain and would otherwise
126-
// pass isTextPlain() and consume the body-map budget meant for genuine form fields. Matches the
127-
// same rawFilename-presence check collectFilesContent() already uses to decide "is this a file".
126+
// pass isTextPlain() and consume the body-map budget meant for genuine form fields. Uses
127+
// hasFilenameParam() rather than rawFilenameFromContentDisposition(), which deliberately ignores
128+
// the RFC 5987 "filename*" form: a part carrying only "filename*" must still be excluded here,
129+
// even though its filename value isn't decoded/reported elsewhere in this file.
128130
private static boolean hasFilename(Map<String, List<String>> headers) {
129131
if (headers == null) {
130132
return false;
@@ -133,7 +135,43 @@ private static boolean hasFilename(Map<String, List<String>> headers) {
133135
if (cdHeaders == null || cdHeaders.isEmpty()) {
134136
return false;
135137
}
136-
return rawFilenameFromContentDisposition(cdHeaders.get(0)) != null;
138+
return hasFilenameParam(cdHeaders.get(0));
139+
}
140+
141+
// Presence-only counterpart of rawFilenameFromContentDisposition(): recognizes both the plain
142+
// "filename" parameter and the RFC 5987 extended "filename*" form (e.g. filename*=UTF-8''a.txt),
143+
// since either form marks the part as a file upload. Unlike rawFilenameFromContentDisposition(),
144+
// this never needs to decode the value, so the RFC 5987 charset/percent-encoding is irrelevant
145+
// here. Shares the same quote-aware semicolon scanning; see rawFilenameFromContentDisposition()
146+
// for the rationale.
147+
private static boolean hasFilenameParam(String cd) {
148+
if (cd == null) return false;
149+
int i = 0;
150+
int len = cd.length();
151+
while (i < len) {
152+
while (i < len && cd.charAt(i) != ';') {
153+
if (cd.charAt(i) == '"') {
154+
i++;
155+
while (i < len && cd.charAt(i) != '"') {
156+
if (cd.charAt(i) == '\\') i++;
157+
i++;
158+
}
159+
}
160+
i++;
161+
}
162+
if (i >= len) break;
163+
i++;
164+
while (i < len && (cd.charAt(i) == ' ' || cd.charAt(i) == '\t')) i++;
165+
if (cd.regionMatches(true, i, "filename", 0, 8)) {
166+
int j = i + 8;
167+
if (j < len && cd.charAt(j) == '*') j++;
168+
while (j < len && (cd.charAt(j) == ' ' || cd.charAt(j) == '\t')) j++;
169+
if (j < len && cd.charAt(j) == '=') {
170+
return true;
171+
}
172+
}
173+
}
174+
return false;
137175
}
138176

139177
public static List<String> collectFilenames(MultipartFormDataInput ret) {

dd-java-agent/instrumentation/resteasy/resteasy-appsec-3.0/src/test/groovy/MultipartHelperTest.groovy

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,24 @@ class MultipartHelperTest extends Specification {
379379
0 * textFile.getBody(_, _)
380380
}
381381
382+
def "collectBodyMap excludes a text/plain part that declares only an RFC 5987 filename*"() {
383+
given: "a file uploaded with filename* only (no plain filename), plus a real text field"
384+
def textFile = Mock(InputPart)
385+
textFile.getHeaders() >> headers('form-data; name="upload"; filename*=UTF-8\'\'notes.txt', 'text/plain')
386+
def text = Mock(InputPart)
387+
text.getHeaders() >> headers('form-data; name="q"')
388+
text.getBody(_, _) >> new ByteArrayInputStream('<script>'.bytes)
389+
def ret = Mock(MultipartFormDataInput)
390+
ret.getFormDataMap() >> ['upload': [textFile], 'q': [text]]
391+
392+
when:
393+
def result = MultipartHelper.collectBodyMap(ret)
394+
395+
then: "the text/plain file part never reaches getBody() and does not consume the body-map cap"
396+
result == ['q': ['<script>']]
397+
0 * textFile.getBody(_, _)
398+
}
399+
382400
def "collectBodyMap does not let dummy file parts exhaust the cap before a real text field"() {
383401
given: "MAX_FILES_TO_INSPECT dummy text/plain file parts followed by one real text field"
384402
def entries = [:]

0 commit comments

Comments
 (0)