Skip to content

Commit f68d117

Browse files
SONARJAVA-6522 Don't raise S2092 for deletion cookies (#5785)
1 parent 5c45a78 commit f68d117

5 files changed

Lines changed: 242 additions & 9 deletions

File tree

its/autoscan/src/test/resources/autoscan/autoscan-diff-by-rules.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,7 @@
764764
{
765765
"ruleKey": "S2092",
766766
"hasTruePositives": true,
767-
"falseNegatives": 96,
767+
"falseNegatives": 99,
768768
"falsePositives": 0
769769
},
770770
{
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"ruleKey": "S2092",
33
"hasTruePositives": true,
4-
"falseNegatives": 96,
4+
"falseNegatives": 99,
55
"falsePositives": 0
66
}

java-checks-test-sources/default/src/main/java/checks/security/SecureCookieCheckSample.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,77 @@ play.mvc.Http.Cookie getC6() {
178178
Http.CookieBuilder getC7() {
179179
return play.mvc.Http.Cookie.builder("theme", "blue").withSecure(false); // Noncompliant
180180
}
181+
182+
void deleteCookieWithEmptyValue(HttpServletResponse response) {
183+
Cookie cookie = new Cookie("name", "");
184+
cookie.setMaxAge(0);
185+
response.addCookie(cookie);
186+
}
187+
188+
void deleteCookieWithNullValue(HttpServletResponse response) {
189+
Cookie cookie = new Cookie("name", null);
190+
cookie.setMaxAge(0);
191+
response.addCookie(cookie);
192+
}
193+
194+
void deleteCookieWithRealValue(String token, HttpServletResponse response) {
195+
Cookie cookie = new Cookie("name", token); // Noncompliant
196+
cookie.setMaxAge(0);
197+
response.addCookie(cookie);
198+
}
199+
200+
void deleteCookieWithoutMaxAge(HttpServletResponse response) {
201+
Cookie cookie = new Cookie("name", null); // Noncompliant
202+
response.addCookie(cookie);
203+
}
204+
205+
void deleteCookieWithNonZeroMaxAge(HttpServletResponse response) {
206+
Cookie cookie = new Cookie("name", null); // Noncompliant
207+
cookie.setMaxAge(60);
208+
response.addCookie(cookie);
209+
}
210+
211+
void deleteCookieWithNonLiteralMaxAge(int maxAge, HttpServletResponse response) {
212+
Cookie cookie = new Cookie("name", ""); // Noncompliant
213+
cookie.setMaxAge(maxAge);
214+
response.addCookie(cookie);
215+
}
216+
217+
void deleteCookieAssignedWithEmptyValue(HttpServletResponse response) {
218+
Cookie cookie;
219+
cookie = new Cookie("name", "");
220+
cookie.setMaxAge(0);
221+
response.addCookie(cookie);
222+
}
223+
224+
void deleteCookieAssignedWithRealValue(String token, HttpServletResponse response) {
225+
Cookie cookie;
226+
cookie = new Cookie("name", token); // Noncompliant
227+
cookie.setMaxAge(0);
228+
response.addCookie(cookie);
229+
}
230+
231+
void deleteCookieWithExplicitSetSecureFalse(HttpServletResponse response) {
232+
Cookie cookie = new Cookie("refreshToken", null);
233+
cookie.setHttpOnly(true);
234+
cookie.setSecure(false);
235+
cookie.setPath("/");
236+
cookie.setMaxAge(0);
237+
response.addCookie(cookie);
238+
}
239+
240+
void deleteCookieWithExplicitSetSecureFalseMaxAgeFirst(HttpServletResponse response) {
241+
Cookie cookie = new Cookie("refreshToken", null);
242+
cookie.setMaxAge(0);
243+
cookie.setSecure(false);
244+
response.addCookie(cookie);
245+
}
246+
247+
void deleteCookieWithExplicitSetSecureFalseNoMaxAge(HttpServletResponse response) {
248+
Cookie cookie = new Cookie("refreshToken", null);
249+
cookie.setSecure(false); // Noncompliant
250+
response.addCookie(cookie);
251+
}
181252
}
182253

183254
class SecureCookieCheckSampleB extends Cookie {
@@ -205,6 +276,9 @@ public Object clone() {
205276
Cookie cloneDifferentType() {
206277
return new Cookie("name", "value"); // Noncompliant
207278
}
279+
void fieldAccessReceiver() {
280+
this.c.setSecure(false); // Noncompliant
281+
}
208282
Date codeCoverage(Cookie cookie) {
209283
SecureCookieCheckSample a = new SecureCookieCheckSample();
210284
a.foo(cookie);

java-checks-test-sources/spring-3.2/src/main/java/checks/SecureCookieCheckSample.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,24 @@ public String cookie() {
2222
return "Enjoy your Cookie!";
2323
}
2424

25+
public void deleteCookie() {
26+
ResponseCookie.from("token", "").maxAge(0).secure(false).httpOnly(true).path("/").build();
27+
}
28+
29+
public void deleteCookieMissingMaxAge() {
30+
ResponseCookie.from("token", "").secure(false).httpOnly(true).path("/").build(); // Noncompliant
31+
}
32+
33+
public void deleteCookieWithRealValue(String token) {
34+
ResponseCookie.from("token", token).maxAge(0).secure(false).httpOnly(true).path("/").build(); // Noncompliant
35+
}
36+
37+
public void deleteCookieMaxAgeAfterSecure() {
38+
ResponseCookie.from("token", "").secure(false).maxAge(0).httpOnly(true).path("/").build();
39+
}
40+
41+
public void deleteCookieWithNonLiteralMaxAge(long maxAge) {
42+
ResponseCookie.from("token", "").maxAge(maxAge).secure(false).httpOnly(true).path("/").build(); // Noncompliant
43+
}
44+
2545
}

java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java

Lines changed: 146 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.List;
2525
import java.util.Map;
2626
import java.util.Set;
27+
import javax.annotation.CheckForNull;
2728
import org.sonar.check.Rule;
2829
import org.sonar.java.checks.helpers.ExpressionsHelper;
2930
import org.sonar.java.model.LiteralUtils;
@@ -54,6 +55,7 @@ public class SecureCookieCheck extends IssuableSubscriptionVisitor {
5455
private static final String JAX_RS_NEW_COOKIE_JAKARTA = "jakarta.ws.rs.core.NewCookie";
5556
private static final String SPRING_SAVED_COOKIE = "org.springframework.security.web.savedrequest.SavedCookie";
5657
private static final String PLAY_COOKIE = "play.mvc.Http$Cookie";
58+
private static final String SPRING_HTTP_COOKIE_BUILDER = "org.springframework.http.ResponseCookie$ResponseCookieBuilder";
5759
private static final List<String> COOKIES = Arrays.asList(
5860
"javax.servlet.http.Cookie",
5961
"jakarta.servlet.http.Cookie",
@@ -67,7 +69,7 @@ public class SecureCookieCheck extends IssuableSubscriptionVisitor {
6769
PLAY_COOKIE,
6870
"play.mvc.Http$CookieBuilder",
6971
"org.springframework.boot.web.server.Cookie",
70-
"org.springframework.http.ResponseCookie$ResponseCookieBuilder");
72+
SPRING_HTTP_COOKIE_BUILDER);
7173

7274
private static final List<String> SETTER_NAMES = Arrays.asList("setSecure", "withSecure", "secure");
7375

@@ -114,8 +116,25 @@ public class SecureCookieCheck extends IssuableSubscriptionVisitor {
114116
.addParametersMatcher(JAVA_LANG_STRING, JAVA_LANG_STRING, "java.lang.Integer", JAVA_LANG_STRING, JAVA_LANG_STRING, BOOLEAN, BOOLEAN, "play.mvc.Http$Cookie$SameSite")
115117
.build();
116118

119+
private static final MethodMatchers RESPONSE_COOKIE_FROM = MethodMatchers.create()
120+
.ofTypes("org.springframework.http.ResponseCookie")
121+
.names("from")
122+
.addParametersMatcher(JAVA_LANG_STRING, JAVA_LANG_STRING)
123+
.build();
124+
125+
private static final MethodMatchers RESPONSE_COOKIE_MAX_AGE_ZERO = MethodMatchers.create()
126+
.ofTypes(SPRING_HTTP_COOKIE_BUILDER)
127+
.names("maxAge")
128+
.addParametersMatcher("long")
129+
.build();
130+
117131
private final Map<Symbol.VariableSymbol, NewClassTree> unsecuredCookies = new HashMap<>();
132+
private final Map<Symbol.VariableSymbol, NewClassTree> deletionCandidateCookies = new HashMap<>();
118133
private final Set<NewClassTree> cookieConstructors = new HashSet<>();
134+
// Both below are pure, order-independent facts recorded as soon as seen and never removed;
135+
// the deletion-cookie decision itself is only made in leaveFile(), once the whole file has been scanned.
136+
private final Set<Symbol.VariableSymbol> maxAgeZeroSeen = new HashSet<>();
137+
private final Map<Symbol.VariableSymbol, MethodInvocationTree> deletionCandidateSecureCalls = new HashMap<>();
119138
private final Deque<Symbol.TypeSymbol> enclosingClass = new ArrayDeque<>();
120139

121140
@Override
@@ -131,24 +150,35 @@ public List<Tree.Kind> nodesToVisit() {
131150
@Override
132151
public void setContext(JavaFileScannerContext context) {
133152
unsecuredCookies.clear();
153+
deletionCandidateCookies.clear();
134154
cookieConstructors.clear();
155+
maxAgeZeroSeen.clear();
156+
deletionCandidateSecureCalls.clear();
135157
enclosingClass.clear();
136158
super.setContext(context);
137159
}
138160

139161
@Override
140162
public void leaveFile(JavaFileScannerContext context) {
141163
cookieConstructors.forEach(r -> reportIssue(r.identifier(), MESSAGE));
164+
deletionCandidateSecureCalls.forEach((symbol, mit) -> {
165+
if (!maxAgeZeroSeen.contains(symbol)) {
166+
reportIssue(mit.arguments(), MESSAGE);
167+
}
168+
});
142169
}
143170

144171
@Override
145172
public void visitNode(Tree tree) {
146173
if (tree.is(Tree.Kind.VARIABLE)) {
147174
addToUnsecuredCookies((VariableTree) tree);
175+
addToDeletionCandidates((VariableTree) tree);
148176
} else if (tree.is(Tree.Kind.ASSIGNMENT)) {
149177
addToUnsecuredCookies((AssignmentExpressionTree) tree);
178+
addToDeletionCandidates((AssignmentExpressionTree) tree);
150179
} else if (tree.is(Tree.Kind.METHOD_INVOCATION)) {
151180
checkSecureCall((MethodInvocationTree) tree);
181+
checkMaxAgeCall((MethodInvocationTree) tree);
152182
} else if (tree.is(Tree.Kind.CLASS)) {
153183
enclosingClass.push(((ClassTree) tree).symbol());
154184
} else {
@@ -192,13 +222,19 @@ private void checkSecureCall(MethodInvocationTree mit) {
192222
ExpressionsHelper.ValueResolution<Boolean> valueResolution = ExpressionsHelper.getConstantValueAsBoolean(mit.arguments().get(0));
193223
Boolean secureArgument = valueResolution.value();
194224
boolean isFalse = secureArgument != null && !secureArgument;
195-
if (isFalse) {
196-
reportIssue(mit.arguments(), MESSAGE, valueResolution.valuePath(), null);
197-
}
198225
ExpressionTree methodObject = ((MemberSelectExpressionTree) mit.methodSelect()).expression();
199-
if (methodObject.is(Tree.Kind.IDENTIFIER)) {
200-
IdentifierTree identifierTree = (IdentifierTree) methodObject;
201-
NewClassTree newClassTree = unsecuredCookies.remove(identifierTree.symbol());
226+
Symbol.VariableSymbol receiverSymbol = variableSymbolOf(methodObject);
227+
if (isFalse && !isDeletionCookieChain(mit)) {
228+
// setMaxAge(0) may appear before or after this call in the same method: for a deletion candidate,
229+
// defer the decision to leaveFile(), which checks maxAgeZeroSeen once the whole file has been scanned.
230+
if (receiverSymbol != null && deletionCandidateCookies.containsKey(receiverSymbol)) {
231+
deletionCandidateSecureCalls.put(receiverSymbol, mit);
232+
} else {
233+
reportIssue(mit.arguments(), MESSAGE, valueResolution.valuePath(), null);
234+
}
235+
}
236+
if (receiverSymbol != null) {
237+
NewClassTree newClassTree = unsecuredCookies.remove(receiverSymbol);
202238
cookieConstructors.remove(newClassTree);
203239
}
204240
}
@@ -210,6 +246,109 @@ private void checkConstructor(NewClassTree tree) {
210246
}
211247
}
212248

249+
/**
250+
* Tracked separately from {@link #unsecuredCookies}: whether a cookie's value is null/empty is independent of
251+
* whether it's secure, so a deletion candidate isn't necessarily a pending "unsecured" issue (e.g. secure=true).
252+
*/
253+
private void addToDeletionCandidates(VariableTree variableTree) {
254+
ExpressionTree initializer = variableTree.initializer();
255+
Symbol variableTreeSymbol = variableTree.symbol();
256+
257+
if (initializer != null && initializer.is(Tree.Kind.NEW_CLASS) && variableTreeSymbol.isVariableSymbol()
258+
&& isCookieClass(variableTreeSymbol.type()) && isDeletionValue((NewClassTree) initializer)) {
259+
deletionCandidateCookies.put((Symbol.VariableSymbol) variableTreeSymbol, (NewClassTree) initializer);
260+
}
261+
}
262+
263+
private void addToDeletionCandidates(AssignmentExpressionTree assignment) {
264+
if (assignment.expression().is(Tree.Kind.NEW_CLASS) && assignment.variable().is(Tree.Kind.IDENTIFIER)) {
265+
IdentifierTree assignmentVariable = (IdentifierTree) assignment.variable();
266+
Symbol assignmentVariableSymbol = assignmentVariable.symbol();
267+
if (isCookieClass(assignmentVariable.symbolType()) && isDeletionValue((NewClassTree) assignment.expression()) && !assignmentVariableSymbol.isUnknown()) {
268+
deletionCandidateCookies.put((Symbol.VariableSymbol) assignmentVariableSymbol, (NewClassTree) assignment.expression());
269+
}
270+
}
271+
}
272+
273+
/**
274+
* Value is always the 2nd constructor argument across the cookie types tracked here (e.g. {@code Cookie(name, value)});
275+
* the {@code >= 2} guard excludes no-arg/name-only overloads rather than acting as a general bounds check.
276+
*/
277+
private static boolean isDeletionValue(NewClassTree newClassTree) {
278+
Arguments arguments = newClassTree.arguments();
279+
return arguments.size() >= 2 && isNullOrEmptyLiteral(arguments.get(1));
280+
}
281+
282+
private void checkMaxAgeCall(MethodInvocationTree mit) {
283+
if (isSetMaxAgeZeroCall(mit) && mit.methodSelect().is(Tree.Kind.MEMBER_SELECT)) {
284+
ExpressionTree methodObject = ((MemberSelectExpressionTree) mit.methodSelect()).expression();
285+
Symbol.VariableSymbol receiverSymbol = variableSymbolOf(methodObject);
286+
if (receiverSymbol != null) {
287+
maxAgeZeroSeen.add(receiverSymbol);
288+
cookieConstructors.remove(deletionCandidateCookies.get(receiverSymbol));
289+
}
290+
}
291+
}
292+
293+
@CheckForNull
294+
private static Symbol.VariableSymbol variableSymbolOf(ExpressionTree expression) {
295+
if (!expression.is(Tree.Kind.IDENTIFIER)) {
296+
return null;
297+
}
298+
Symbol symbol = ((IdentifierTree) expression).symbol();
299+
return symbol.isVariableSymbol() ? (Symbol.VariableSymbol) symbol : null;
300+
}
301+
302+
private static boolean isSetMaxAgeZeroCall(MethodInvocationTree mit) {
303+
return mit.arguments().size() == 1
304+
&& !mit.methodSymbol().isUnknown()
305+
&& !mit.methodSymbol().owner().isUnknown()
306+
&& isCookieClass(mit.methodSymbol().owner().type())
307+
&& "setMaxAge".equals(getIdentifier(mit).name())
308+
&& LiteralUtils.isZero(mit.arguments().get(0));
309+
}
310+
311+
/**
312+
* Only suppresses the Spring builder path: the servlet/JAX-RS Cookie constructor path is handled separately
313+
* by {@link #deletionCandidateCookies}/{@link #checkMaxAgeCall}, since it never reaches this method.
314+
* Walks the whole fluent chain rather than just {@code mit}'s receivers, since {@code .from(...)}
315+
* and {@code .maxAge(0)} can appear in either order relative to the {@code secure(false)} call being checked.
316+
*/
317+
private static boolean isDeletionCookieChain(MethodInvocationTree mit) {
318+
boolean hasEmptyOrNullValue = false;
319+
boolean hasMaxAgeZero = false;
320+
ExpressionTree current = chainRoot(mit);
321+
while (current != null && current.is(Tree.Kind.METHOD_INVOCATION)) {
322+
MethodInvocationTree currentMit = (MethodInvocationTree) current;
323+
if (RESPONSE_COOKIE_FROM.matches(currentMit)) {
324+
hasEmptyOrNullValue = isNullOrEmptyLiteral(currentMit.arguments().get(1));
325+
} else if (RESPONSE_COOKIE_MAX_AGE_ZERO.matches(currentMit) && LiteralUtils.isZero(currentMit.arguments().get(0))) {
326+
hasMaxAgeZero = true;
327+
}
328+
ExpressionTree methodSelect = currentMit.methodSelect();
329+
current = methodSelect.is(Tree.Kind.MEMBER_SELECT) ? ((MemberSelectExpressionTree) methodSelect).expression() : null;
330+
}
331+
return hasEmptyOrNullValue && hasMaxAgeZero;
332+
}
333+
334+
/**
335+
* Climbs to the outermost method call of the fluent chain {@code mit} belongs to, so the whole chain
336+
* (including calls made after {@code mit}, e.g. {@code secure(false)} followed by {@code maxAge(0)}) gets inspected.
337+
*/
338+
private static ExpressionTree chainRoot(MethodInvocationTree mit) {
339+
ExpressionTree root = mit;
340+
Tree parent = mit.parent();
341+
while (parent != null && parent.is(Tree.Kind.MEMBER_SELECT) && parent.parent() != null && parent.parent().is(Tree.Kind.METHOD_INVOCATION)) {
342+
root = (ExpressionTree) parent.parent();
343+
parent = root.parent();
344+
}
345+
return root;
346+
}
347+
348+
private static boolean isNullOrEmptyLiteral(ExpressionTree expression) {
349+
return expression.is(Tree.Kind.NULL_LITERAL) || LiteralUtils.isEmptyString(expression);
350+
}
351+
213352
private boolean isSelfInstantiation(NewClassTree tree) {
214353
Symbol.TypeSymbol enclosing = enclosingClass.peek();
215354
return enclosing != null && !tree.symbolType().isUnknown() && tree.symbolType().equals(enclosing.type());

0 commit comments

Comments
 (0)