Skip to content

SONARJAVA-6522 Don't raise S2092 for deletion cookies#5785

Merged
nils-werner-sonarsource merged 7 commits into
masterfrom
sonarjava-6522-cookie-deletion-pattern
Jul 14, 2026
Merged

SONARJAVA-6522 Don't raise S2092 for deletion cookies#5785
nils-werner-sonarsource merged 7 commits into
masterfrom
sonarjava-6522-cookie-deletion-pattern

Conversation

@nils-werner-sonarsource

@nils-werner-sonarsource nils-werner-sonarsource commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

S2092 (SecureCookieCheck) flags the common "delete this cookie" idiom — new Cookie(name, null)/new Cookie(name, "") combined with setMaxAge(0), or the Spring equivalent ResponseCookie.from(name, "").maxAge(0)... — as if it were a real, value-carrying cookie missing its secure flag. A deletion cookie carries no sensitive value, so whether it's marked secure is moot, but the rule doesn't know that.

SONARJAVA-6522

Approach

Guiding principle (shared with the sibling ticket SONARJAVA-6523, #5781): favor fewer false positives, and fail safe on ambiguity — if either condition can't be resolved to a literal, don't suppress, keep raising as before.

The ticket spans two independent code paths in SecureCookieCheck.java, since they behave differently today:

Question Decision
javax.servlet.http.Cookie/jakarta.servlet.http.Cookie: how to detect the deletion pattern? new Cookie(name, value) (2-arg constructor, no secure param) is always flagged today unless a later setSecure(true) cancels it — this is where the real FP lives. Added a second, independent Map<Symbol.VariableSymbol, NewClassTree> (deletionCandidateCookies), populated when the constructor's value argument is a literal null/"", and consumed by a new checkMaxAgeCall when a literal setMaxAge(0) is later seen on the same variable — mirroring the existing unsecuredCookies/checkSecureCall cancellation idiom already in this file.
Separate map or generalize unsecuredCookies? Separate map — "value is null/empty" and "secure is false" are orthogonal facts about the same constructor call; keeps each map single-purpose.
ResponseCookie.from(name, value).maxAge(0)...secure(false): how to detect the deletion pattern? This builder is never flagged by itself today (no default-insecure assumption for builders) — only an explicit .secure(false) in the chain triggers it, reported immediately. Since it's a single self-contained expression tree, no new tracking map is needed: added a backward structural walk (isDeletionCookieChain) from the secure(false) call via methodSelect().expression(), looking for a .maxAge(0) and a root .from(name, null/"") call anywhere in the chain. Order-independent by construction (it's a tree walk, not sequential-visitation-based).
Precise MethodMatchers for .maxAge(...), or name+literal-arg matching? Precise MethodMatchers for maxAge(long), consistent with this file's existing style for constructor-overload matching, and naturally excludes the Duration overload (out of scope per the ticket's literal-only requirement).

Implementation

SecureCookieCheck.java: new deletionCandidateCookies map + addToDeletionCandidates/checkMaxAgeCall for the servlet/JAX-RS Cookie path; new RESPONSE_COOKIE_FROM/RESPONSE_COOKIE_MAX_AGE_ZERO matchers + isDeletionCookieChain backward chain-walk for the Spring ResponseCookie builder path; one shared isNullOrEmptyLiteral helper used by both. No new abstractions beyond what's needed.

Tests

SecureCookieCheckSample.java (default sources) — servlet Cookie deletion pattern:

  • Compliant: empty/null value + setMaxAge(0).
  • Noncompliant: real value (Condition A fails), missing setMaxAge call (Condition B fails), non-zero setMaxAge (Condition B fails), non-literal setMaxAge argument (ambiguous, fails safe).

SecureCookieCheckSample.java (spring-3.2 module) — ResponseCookie builder chain:

  • Compliant: empty value + .maxAge(0) + .secure(false) in the same chain.
  • Noncompliant: missing .maxAge(0), and real value with .maxAge(0) present (Condition A fails).

Test plan

  • mvn -pl java-checks test -Dtest="SecureCookieCheckTest#test,SecureCookieCheckTest#test_jakarta,SecureCookieCheckTest#test_non_compiling" — all pass
  • Confirmed SecureCookieCheckTest#test_with_spring_3_2 fails identically (same "expect some issues, but there's none" pre-existing environment failure) on unmodified master and on this branch — pre-existing, unrelated to this change (same finding as SONARJAVA-6523 Don't raise S2092 on Cookie subtype self-instantiation #5781)
  • Ran the full org.sonar.java.checks.security.*Test suite — identical set of 15 pre-existing unrelated failures (Android/WebView/ClearTextProtocol/CookieHttpOnly/DebugFeatureEnabled/EmptyDatabasePassword/ReceivingIntents + the known SecureCookieCheckTest spring-3.2 failure) on this branch; no new regressions introduced

new Cookie(name, null/"") + setMaxAge(0) is a common idiom for
clearing an existing cookie, not storing a value, so whether it's
marked secure is moot. Skip reporting when both the literal
null/empty value and the literal setMaxAge(0)/maxAge(0) call are
present for the same cookie; ambiguous (non-literal) cases still
raise as before. Covers javax/jakarta servlet Cookie's
constructor+setter pattern and Spring's ResponseCookie.from(...)
fluent builder chain.
@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

SONARJAVA-6522

The new deletion-cookie suppression depends on resolved semantic
types (constructor value literal, maxAge argument, ResponseCookie
builder chain), which AutoScan's reduced-semantics mode can't
resolve. The fail-safe guards correctly keep raising in that mode,
widening the known Maven-vs-AutoScan divergence for S2092 by 2.
isDeletionCookieChain only walked backward from the secure(false) call
via its receiver chain, so maxAge(0) placed after secure(false) in the
same fluent chain (e.g. .secure(false).maxAge(0)) was invisible and
still raised. Climb to the chain's outermost call first via parent()
before walking, so the whole chain is inspected regardless of call
order.

Addresses Gitar review feedback on PR #5785.
@nils-werner-sonarsource

Copy link
Copy Markdown
Contributor Author

Fixed — good catch. `isDeletionCookieChain` only walked backward from the `secure(false)` call via its receiver chain, so `maxAge(0)` placed after `secure(false)` in the same fluent chain was structurally an ancestor, not reachable by that walk. Added a `chainRoot` helper that climbs to the outermost call of the fluent chain via `Tree.parent()` before walking, so the whole chain is inspected regardless of call order. Added a regression test (`deleteCookieMaxAgeAfterSecure`) covering `.secure(false).maxAge(0)` ordering.

Document why deletionCandidateCookies is a separate map from
unsecuredCookies, why isDeletionValue assumes argument index 1, and
why isDeletionCookieChain walks the whole chain instead of just the
receiver sub-chain.
Boolean secureArgument = valueResolution.value();
boolean isFalse = secureArgument != null && !secureArgument;
if (isFalse) {
if (isFalse && !isDeletionCookieChain(mit)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suppresses the wrong path for the servlet/JAX-RS Cookie case. isDeletionCookieChain (per its own javadoc) only recognizes the Spring ResponseCookie fluent-chain pattern — it walks receiver MethodInvocationTrees. For cookie.setSecure(false) where cookie is a plain identifier, the receiver is an IdentifierTree, so the loop in isDeletionCookieChain never executes and it always returns false. !isDeletionCookieChain(mit) is therefore always true here, and this line unconditionally reports whenever secure(false) is a literal — regardless of whether the cookie is a deletion cookie.

The deletionCandidateCookies/checkMaxAgeCall mechanism only removes the entry from cookieConstructors, which is consulted only in leaveFile() (for cookies that were never explicitly secured). It's never consulted here.

Repro — this is the ticket's own second compliant example:

Cookie cookie = new Cookie("refreshToken", null);
cookie.setHttpOnly(true);
cookie.setSecure(false);
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);

Running SecureCookieCheck against this still raises S2092 on the cookie.setSecure(false) line. Verified with both call orders (setMaxAge(0) before and after setSecure(false)) — same result both times, so it's not an ordering issue.

None of the new sample cases added in this PR call .setSecure(false) explicitly (they only rely on the never-explicitly-secured constructor path), so this gap has no test coverage.

Suggested fix: this method needs to also check deletionCandidateCookies for the receiver's symbol (like checkMaxAgeCall does) before reporting, not just rely on the Spring-chain check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed and fixed. isDeletionCookieChain was indeed Spring-chain-only; the servlet/JAX-RS Cookie explicit setSecure(false) case never consulted deletionCandidateCookies at all.

Fixed by deferring the report decision to leaveFile() for deletion-candidate variables: setMaxAge(0) sightings are now recorded in a plain, never-removed fact set, so the check no longer cares whether setMaxAge(0) comes before or after setSecure(false) in the method. Added test coverage for both statement orders (including your exact repro) plus the still-raises case with no setMaxAge call at all.

isDeletionCookieChain only recognizes the Spring ResponseCookie
fluent-chain pattern, so an explicit cookie.setSecure(false) on a
servlet/JAX-RS Cookie deletion candidate was never suppressed and
always reported immediately, regardless of a later/earlier
setMaxAge(0) call on the same variable (the ticket's own second
compliant example).

Defer the report decision to leaveFile() for deletion candidates:
record setMaxAge(0) sightings in a plain fact set (never removed, so
order relative to setSecure(false) doesn't matter), and only report
the deferred setSecure(false) call if that fact is still absent once
the whole file has been scanned. Also fixes checkMaxAgeCall to stop
removing from deletionCandidateCookies, since checkSecureCall may
still need to consult it later in the same method.

Adds test coverage for both statement orders, the still-raises case
with no setMaxAge call, and the assignment-form (non-declaration)
deletion-candidate tracking path.

Addresses review feedback from Luqman on PR #5785.
Comment thread java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java Outdated
…al maxAge

- checkSecureCall's receiverSymbol branch, when the setSecure(false)
  receiver is a field access (this.c) rather than a bare identifier.
- isDeletionCookieChain's maxAge branch, when the argument isn't a
  literal zero (out of scope per the ticket, must still raise).

@Luqmansonar Luqmansonar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

checkSecureCall and checkMaxAgeCall cast an IdentifierTree's symbol
directly to Symbol.VariableSymbol without checking isVariableSymbol()
first, unlike the sibling addToUnsecuredCookies/addToDeletionCandidates
methods and unlike the code this replaced (Map.remove(Object) never
needed the cast in the first place). Extract a shared variableSymbolOf
helper that guards the cast, consistent with the file's existing style
and robust on non-compiling sources.

Also bump the autoscan golden diff for S2092 (98->99 FN) to reflect
the two new realistic test cases added for coverage in the previous
commit.

Addresses Gitar review feedback on PR #5785.
@gitar-bot

gitar-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 2 resolved / 2 findings

Updates S2092 to suppress false positives for cookie deletion patterns in Servlet and Spring ResponseCookie APIs. Resolved issues include the unguarded identifier symbol cast and incorrect maxAge(0) detection within method chains.

✅ 2 resolved
Edge Case: Spring chain suppression misses maxAge(0) placed after secure(false)

📄 java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java:276-290 📄 java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java:213
isDeletionCookieChain walks only inward via methodSelect().expression() from the secure(false) call, so it can only see chained calls that precede secure(false). A common deletion idiom like ResponseCookie.from("token", "").secure(false).maxAge(0).build() still emits a false positive because .maxAge(0) is an ancestor of the secure node, not reachable by the backward walk. The PR description claims this is 'order-independent by construction', but it is only order-independent for calls appearing before secure(). Consider walking the full enclosing chain (or tracking the whole from(...) root expression) rather than only the receiver sub-chain, and add a test for the secure(false) before maxAge(0) ordering.

Bug: Unguarded cast of identifier symbol to VariableSymbol

📄 java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java:225 📄 java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java:285
Both checkSecureCall (line 225) and checkMaxAgeCall (line 285) now cast ((IdentifierTree) methodObject).symbol() directly to Symbol.VariableSymbol without an isVariableSymbol() check. The code these replaced avoided the cast, and the sibling methods (addToUnsecuredCookies, addToDeletionCandidates) guard with isVariableSymbol()/!isUnknown(). If the receiver identifier ever resolves to a non-variable or unknown symbol, this throws ClassCastException. While the surrounding isSetSecureCall/isSetMaxAgeZeroCall guards make this hard to reach for compiling code, adding the guard keeps the new code consistent with the file's defensive style and fully robust on non-compiling sources.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@nils-werner-sonarsource nils-werner-sonarsource enabled auto-merge (squash) July 14, 2026 11:36
@sonarqube-next

Copy link
Copy Markdown

@nils-werner-sonarsource nils-werner-sonarsource merged commit f68d117 into master Jul 14, 2026
17 checks passed
@nils-werner-sonarsource nils-werner-sonarsource deleted the sonarjava-6522-cookie-deletion-pattern branch July 14, 2026 11:51
@gitar-bot

gitar-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 2 resolved / 2 findings

Updates S2092 to suppress false positives for cookie deletion patterns in Servlet and Spring ResponseCookie APIs. Resolved issues include the unguarded identifier symbol cast and incorrect maxAge(0) detection within method chains.

✅ 2 resolved
Edge Case: Spring chain suppression misses maxAge(0) placed after secure(false)

📄 java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java:276-290 📄 java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java:213
isDeletionCookieChain walks only inward via methodSelect().expression() from the secure(false) call, so it can only see chained calls that precede secure(false). A common deletion idiom like ResponseCookie.from("token", "").secure(false).maxAge(0).build() still emits a false positive because .maxAge(0) is an ancestor of the secure node, not reachable by the backward walk. The PR description claims this is 'order-independent by construction', but it is only order-independent for calls appearing before secure(). Consider walking the full enclosing chain (or tracking the whole from(...) root expression) rather than only the receiver sub-chain, and add a test for the secure(false) before maxAge(0) ordering.

Bug: Unguarded cast of identifier symbol to VariableSymbol

📄 java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java:225 📄 java-checks/src/main/java/org/sonar/java/checks/security/SecureCookieCheck.java:285
Both checkSecureCall (line 225) and checkMaxAgeCall (line 285) now cast ((IdentifierTree) methodObject).symbol() directly to Symbol.VariableSymbol without an isVariableSymbol() check. The code these replaced avoided the cast, and the sibling methods (addToUnsecuredCookies, addToDeletionCandidates) guard with isVariableSymbol()/!isUnknown(). If the receiver identifier ever resolves to a non-variable or unknown symbol, this throws ClassCastException. While the surrounding isSetSecureCall/isSetMaxAgeZeroCall guards make this hard to reach for compiling code, adding the guard keeps the new code consistent with the file's defensive style and fully robust on non-compiling sources.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants