2424import java .util .List ;
2525import java .util .Map ;
2626import java .util .Set ;
27+ import javax .annotation .CheckForNull ;
2728import org .sonar .check .Rule ;
2829import org .sonar .java .checks .helpers .ExpressionsHelper ;
2930import 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