Repository navigation
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
praise: The fix sits where the bug is, and the new tests prove it.
upstream()turns the silent NPE intoIOException("upstream not connected"); both integration tests fail at BASE (the 152-byte NPE phrase makes the EclipseCloseReasonthrow, soclient.closeis never called) and pass at HEAD.- Every
CloseReasonin the class goes throughcloseReason(), includingTRY_AGAIN_LATERinstart(), where at BASE anIllegalArgumentExceptionwould have escapedcatch (IOException e1). SeparatedValuesReader implements Closeableis enough for CodeQL: alert #34 has no instance on the PR merge ref.
issue (non-blocking): closeReason() can split a surrogate pair and return a phrase that ends in a lone high surrogate.
openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:56-59
Line 56 cuts at 123 chars and the loop drops one UTF-16 unit at a time. getBytes(UTF_8) counts a lone surrogate as one byte ('?'), so the loop can stop with the high half still attached. Measured: "x" + U+1F600 x100 gives 62 chars / 122 bytes ending in \uD83D, and "a" x122 + U+1F600 x5 splits at line 56 alone. The close still goes out (Tomcat encodes the phrase with String.getBytes, so the client sees a trailing ?), but this contradicts the Javadoc, the test comment "never split a character" and the description.
static CloseReason closeReason(CloseReason.CloseCode code, String phrase) {
String reason = phrase.length() > MAX_REASON_PHRASE_BYTES ? phrase.substring(0, MAX_REASON_PHRASE_BYTES) : phrase;
while (reason.getBytes(StandardCharsets.UTF_8).length > MAX_REASON_PHRASE_BYTES
|| (!reason.isEmpty() && Character.isHighSurrogate(reason.charAt(reason.length() - 1)))) {
reason = reason.substring(0, reason.length() - 1);
}
return new CloseReason(code, reason);
}Pin, in shouldTruncateCloseReasonPhraseToProtocolLimit: closeReason(CLOSED_ABNORMALLY, "x" + "😀".repeat(100)).getReasonPhrase() is "x" + "😀".repeat(30). It is red at HEAD, which returns the same string plus a lone \uD83D.
suggestion (non-blocking): No test sends a phrase over 123 bytes through closeAbnormally(), so the closeReason() routing has no pin.
openig-core/src/test/java/org/openidentityplatform/openig/websocket/ServerEndPointTest.java:51-71, openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:89
Both integration tests close with "client message error: java.io.IOException: upstream not connected" (65 bytes), which closeReason() returns unchanged. If closeReason(...) at line 89 is replaced by new CloseReason(...), every test stays green, and that change brings back the long-message failure this PR fixes. The case below covers it and needs no 5 s wait. With that mutant, closeAbnormally throws IllegalArgumentException out of the catch block and the test fails. Deleting closeUpstream(reason) from closeAbnormally also turns it red.
@Test
public void shouldTruncateLongUpstreamErrorWhenClosingClient() throws Exception {
Session upstream = mock(Session.class);
RemoteEndpoint.Basic remote = mock(RemoteEndpoint.Basic.class);
when(upstream.isOpen()).thenReturn(true);
when(upstream.getBasicRemote()).thenReturn(remote);
doThrow(new IOException("x".repeat(200))).when(remote).sendText("hello", true);
endpoint.session_upstream = upstream;
endpoint.echoTextMessage(client, "hello", true);
CloseReason reason = closeReasonOf(client);
assertThat(reason.getReasonPhrase().getBytes(StandardCharsets.UTF_8).length).isLessThanOrEqualTo(123);
verify(upstream).close(reason);
}suggestion (non-blocking): Every test runs with session_upstream == null, so the one changed outcome for a non-null upstream has no test.
openig-core/src/test/java/org/openidentityplatform/openig/websocket/ServerEndPointTest.java:48
Take an upstream that is set but already closed. BASE calls getBasicRemote() on it, gets an NPE and leaves the client open. HEAD throws "upstream not connected", closes the stale session and clears the field. start(), end() and onError() are not called by any test either; for end()/onError() with a null upstream, BASE behaves the same, so they need no pin. The case below is red at BASE and takes the same ~5 s wait as the existing two.
@Test
public void shouldCloseStaleUpstreamWhenItIsNotOpen() throws Exception {
Session upstream = mock(Session.class);
when(upstream.isOpen()).thenReturn(false);
endpoint.session_upstream = upstream;
endpoint.echoTextMessage(client, "hello", true);
assertThat(closeReasonOf(client).getReasonPhrase()).contains("upstream not connected");
verify(upstream).close(any(CloseReason.class));
assertThat(endpoint.session_upstream).isNull();
}suggestion (non-blocking): The wait loop in upstream() still reads the volatile field twice.
openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:66-69
session_upstream==null || !session_upstream.isOpen() can throw an NPE if closeUpstream() runs between the two reads. Nothing observable follows on Tomcat, because every path that clears the field closes the client first. Still, this is the java/dereferenced-value-may-be-null pattern the PR removes elsewhere, and the snapshot is already there one line later.
int ct=0;
Session upstream=session_upstream;
while ((ct++<5000)&&(upstream==null || !upstream.isOpen())) {
Thread.sleep(1);
upstream=session_upstream;
}
if (upstream==null || !upstream.isOpen()) {
throw new IOException("upstream not connected");
}
return upstream;suggestion (non-blocking): getRecord() still creates the FileInputStream inside the reader argument, so it is not closed if the InputStreamReader constructor throws.
openig-core/src/main/java/org/forgerock/openig/text/SeparatedValuesFile.java:152-154
A null charset passed through the public SeparatedValuesFile(File, Charset, ...) constructors makes new InputStreamReader(stream, null) throw after the file is open. The same happens at BASE, and the only in-repo caller (FileAttributesFilter.java:134) never passes null.
try (FileInputStream stream = new FileInputStream(file);
SeparatedValuesReader reader = new SeparatedValuesReader(new InputStreamReader(stream, charset), separator)) {nitpick (non-blocking): The description says the client is always left open at BASE; that holds only where CloseReason enforces the 123-byte limit.
openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:51
The websocket API is provided. Tomcat 11's CloseReason(CloseCode, String) has no length check, and WsSession truncates the phrase itself, so on the openig-docker image (tomcat:11-jre25) BASE already closed the client and only the phrase changes. The bug reproduces with the Eclipse API: the unit-test classpath, Jetty 11+ and Tyrus. The fix is right everywhere; only the stated production impact needs that qualifier.
nitpick (non-blocking): The upstream MessageHandler catches still close the upstream inline, not through closeUpstream().
openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:155-157, :177-179
The description says upstream sessions are closed "null-safely in one place". These two sites close the onOpen parameter that shadows the field. The outcome is the same: the parameter is never null, and session_client.close(reason) runs end() -> closeUpstream() first on Tomcat. Either reword the description or mention these two sites.
note (non-blocking): Description vs code, no reason stated in the PR.
ServerEndPoint.java: "without splitting multi-byte characters" (see thecloseReason()issue); "null-safely in one place" (upstream handler catches, double read inupstream()); "the client connection is left open" (EclipseCloseReasononly).
…ic (#180) Closes the cosmetic `note`-level code scanning alerts #16–#22, #27, #28 and #41. No behavior change. | Alert | Rule | File | Change | |---|---|---|---| | #18 | `java/missing-override-annotation` | `CookieFilter.java` | `@Override` on `CookiePolicy.shouldAccept` | | #16 | `java/missing-override-annotation` | `AdminHttpApplication.java` | `@Override` on `getApiProducer` | | #17 | `java/missing-override-annotation` | `StreamPatternMatches.java` | `@Override` on `close` | | #19–#22 | `java/missing-override-annotation` | `Resolvers.java` | `@Override` on `iterator` / `hasNext` / `next` / `remove` | | #27 | `java/non-static-nested-class` | `HeapImpl.java` | `DecorationContext` → `static` | | #28 | `java/non-static-nested-class` | `HeadlessAuthenticationFilter.java` | `SsoTokenHolder` → `static` | | #41 | `java/unused-parameter` | `OAuth2Utils.java` | `sessionKey(context, uri)` → `sessionKey(uri)` (package-private static, three in-class callers updated) | Deferred: #23 / #24 (`ServerEndPoint`, two more `@Override`) until #176 lands, to keep that file out of two open PRs. Verified: `openig-core` 1002 tests, `openig-oauth2` 158, `openig-openam` 118, javadoc with `failOnWarnings`.
4d1b964 to
1ffac86
Compare
|
All points taken; the round is in 1ffac86 (the branch was rebased onto the current
Each new check was confirmed red against its mutant (no surrogate check in |
maximthomas
left a comment
There was a problem hiding this comment.
praise: Every round-1 point is in, and each fix comes with a test.
closeReason()now stops on a trailing high surrogate (ServerEndPoint.java:61-63), and both emoji cases are pinned inshouldTruncateCloseReasonPhraseToProtocolLimit.shouldTruncateLongUpstreamErrorWhenClosingClientpins thecloseAbnormally()→closeReason()routing.upstream()re-reads a local snapshot (ServerEndPoint.java:71-75), andgetRecord()holds theFileInputStreamas its own resource (SeparatedValuesFile.java:152-155).- The pre-cut also fixes Tomcat. At BASE, Tomcat's
WsSession.appendCloseReasonWithTruncationthrowsStringIndexOutOfBoundsExceptionfor a phrase over 123 bytes that has supplementary characters and at most 120 UTF-16 units, so no close frame went out (reflection probe on Tomcat 11.0.7/11.0.20). HEAD sends valid UTF-8 of 123 bytes or less.
issue (non-blocking): No test runs the rewritten getRecord(). FileAttributesFilterTest mocks SeparatedValuesFile.
openig-core/src/main/java/org/forgerock/openig/text/SeparatedValuesFile.java:150-187, openig-core/src/test/java/org/forgerock/openig/filter/FileAttributesFilterTest.java:40-41, :51, :66
The description says the change is "covered by FileAttributesFilterTest". That test declares @Mock private SeparatedValuesFile file and stubs getRecord(). No other test or route config references SeparatedValuesFile, SeparatedValuesReader or FileAttributesFilter. So nothing in CI runs the new try-with-resources, the header read or the key lookup: break the lookup and every test stays green.
package org.forgerock.openig.text;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.File;
import java.nio.file.Files;
import org.testng.annotations.Test;
@SuppressWarnings("javadoc")
public class SeparatedValuesFileTest {
@Test
public void shouldReadRecordFromFile() throws Exception {
File csv = File.createTempFile("users", ".csv");
csv.deleteOnExit();
Files.write(csv.toPath(), "username,password\njoe,secret\nbob,hunter2\n".getBytes(UTF_8));
SeparatedValuesFile file = new SeparatedValuesFile(csv);
assertThat(file.getRecord("username", "bob")).containsEntry("password", "hunter2");
assertThat(file.getRecord("username", "nobody")).isNull();
assertThat(file.getRecord("missing", "joe")).isNull();
}
}Pin: this runs the code inside the new try block. It cannot see whether the FileInputStream gets closed. Or: drop "covered by FileAttributesFilterTest" from the description.
question (non-blocking): Is it intended that a cut close reason no longer ends with Tomcat's … marker on the shipped image?
openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:58-66, used at :95, :157, :179, :205, :222
Tomcat 11's WsSession.appendCloseReasonWithTruncation writes a phrase of 123 bytes or less as it is. It cuts only longer phrases, to 120 bytes plus U+2026 (javap, 11.0.7). closeReason() now cuts to 123 bytes first, so Tomcat never adds the marker. Example: upstream down: jakarta.websocket.DeploymentException: The HTTP request to initiate the WebSocket connection to [ws://backend:8080/ws] failed (140 B) used to go out as 120 B + … on tomcat:11-jre25, and now goes out as a bare 123-byte cut. A client or log reader can no longer tell a cut reason from a complete one. Close codes are unchanged and nothing breaks. If the bare cut is intended, nothing changes. If the marker should stay, here is a version that keeps it on every container:
static CloseReason closeReason(CloseReason.CloseCode code, String phrase) {
if (phrase.getBytes(StandardCharsets.UTF_8).length <= MAX_REASON_PHRASE_BYTES) {
return new CloseReason(code, truncate(phrase, MAX_REASON_PHRASE_BYTES));
}
// mark the cut as Tomcat does: at most 120 bytes, then U+2026 (3 bytes in UTF-8)
return new CloseReason(code, truncate(phrase, MAX_REASON_PHRASE_BYTES - 3) + "…");
}
private static String truncate(String phrase, int maxBytes) {
String reason = phrase.length() > maxBytes ? phrase.substring(0, maxBytes) : phrase;
// a lone high surrogate is encoded as a single '?' byte, so the length check alone does not catch it
while (reason.getBytes(StandardCharsets.UTF_8).length > maxBytes
|| (!reason.isEmpty() && Character.isHighSurrogate(reason.charAt(reason.length() - 1)))) {
reason = reason.substring(0, reason.length() - 1);
}
return reason;
}Pin: in shouldTruncateCloseReasonPhraseToProtocolLimit, the expected results become "x"×120 + …, Cyrillic ×60 + … (123 bytes), "x" + U+1F600 ×29 + …, and "a"×120 + …. "short" stays as it is. In shouldTruncateLongUpstreamErrorWhenClosingClient, startsWith(...) and hasSize(123) still hold.
question (non-blocking): Is it intended that the echo handlers no longer call authorize() when upstream() gives up?
openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:231-232, :245-246
At BASE the wait loop ended, authorize() ran, and then came the NPE. At HEAD, upstream() throws before authorize(). Take a client whose upstream is gone and whose access was revoked: Principal.authorize() re-runs the gateway route once its TTL (180 s by default) has expired. At BASE that client was closed with client message error: access revoked. Now it gets … upstream not connected, and the route call is skipped. Both versions close with the same code and forward nothing. No test pins either order, because setUp stubs authorize() and nothing verifies it.
// if the HEAD order is intended — in shouldCloseClientWithClearReasonWhenUpstreamIsNotConnectedOnTextMessage
// (import static org.mockito.Mockito.never;)
verify(endpoint.principal, never()).authorize();Pin: this assertion fails if authorize() moves back before the upstream check. Or, if you want BASE's order back, put authorize(); before final Session upstream=upstream(); at both lines. A revoked client is then closed with access revoked and skips the 5 s wait. The pin then becomes verify(endpoint.principal).authorize();.
suggestion (non-blocking): onError() closes the client before the upstream, so on Tomcat the upstream never gets the client error: … reason.
openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:216-222
Tomcat's WsSession.doClose fires @OnClose on the calling thread. So session_client.close() first runs end(), which closes the upstream with NORMAL_CLOSURE and clears the field. The closeUpstream(closeReason(..., "client error: " + t)) at :222 then finds the field null. This was the same at BASE. If the upstream should learn that the client failed:
@OnError
public void onError(Session session_client,Throwable t) throws Throwable {
closeUpstream(closeReason(CloseReason.CloseCodes.CLOSED_ABNORMALLY,"client error: "+t.toString()));
try {
session_client.close();
} catch (Throwable e1) {}
}On the wire, Tomcat then sends 1002 (PROTOCOL_ERROR) to the upstream instead of 1000, because sendCloseMessage rewrites 1006 to 1002. If 1000 is the signal you want, leave it.
nitpick (non-blocking): No test reaches the !reason.isEmpty() guard in closeReason().
openig-core/src/main/java/org/openidentityplatform/openig/websocket/ServerEndPoint.java:62
None of the test phrases is empty or ends up empty, so dropping the guard leaves every test green. Without the guard, a phrase made only of a high surrogate would throw StringIndexOutOfBoundsException from charAt(-1).
assertThat(ServerEndPoint.closeReason(CloseReason.CloseCodes.CLOSED_ABNORMALLY, "\uD83D").getReasonPhrase()).isEmpty();ServerEndPoint dereferenced session_upstream after waiting for it, relying on the resulting NullPointerException to close the client. With the long NPE messages of Java 14+ the close never happened: CloseReason rejects reason phrases over 123 UTF-8 bytes, the IllegalArgumentException was swallowed, and the client connection was left open. - wait for the upstream in upstream() and fail with an explicit "upstream not connected" IOException - build every CloseReason through closeReason(), which truncates the phrase to the 123-byte protocol limit - close the upstream null-safely in one place and make the field volatile (java/dereferenced-value-may-be-null) - SeparatedValuesFile: open the reader in try-with-resources (java/input-resource-leak)
Closes the java/missing-override-annotation notes OpenIdentityPlatform#23 and OpenIdentityPlatform#24.
- closeReason(): never end the phrase in a lone high surrogate; a dangling half is encoded as a single '?' byte and slipped past the length check - upstream(): read the volatile field once per iteration instead of twice in the wait loop - SeparatedValuesFile: open the FileInputStream as a resource of its own so it is closed even if the InputStreamReader constructor throws - tests: pin the surrogate cut, the truncation of a long upstream error in closeAbnormally(), and the close of a stale (not open) upstream
- closeReason(): mark a cut phrase with U+2026 after at most 120 bytes, as Tomcat's WsSession does, so a truncated reason reads the same on every container - echo handlers: call authorize() before waiting for the upstream, so a revoked client is closed with "access revoked" without the ~5 s wait - onError(): close the upstream before the client; on Tomcat close() runs @onclose first and the upstream never got the client error - tests: SeparatedValuesFileTest for getRecord() (FileAttributesFilterTest mocks SeparatedValuesFile), pins for the authorize() order, the onError() order and the empty-phrase guard in closeReason()
1ffac86 to
d1a4ade
Compare
|
All five points taken; the round is in d1a4ade (the branch was rebased onto the current
Each new check was confirmed red against its mutant: the key lookup in |
maximthomas
left a comment
There was a problem hiding this comment.
praise: Every round-2 point is in the code, and the ones about behaviour have tests.
closeReason()now cuts at 120 bytes and appends U+2026, the same way Tomcat'sWsSessiondoes (ServerEndPoint.java:58-64). The empty-phrase guard is pinned (ServerEndPointTest.java:171-172).authorize()runs beforeupstream(), pinned byshouldCloseRevokedClientWithoutWaitingForUpstreamOnTextMessageand…OnBinaryMessage.onError()closes the upstream first (ServerEndPoint.java:229), andshouldCloseUpstreamWithClientErrorOnErrorfails if that order is reverted.SeparatedValuesFileTestruns the realgetRecord()against a CSV file.
Closes code scanning alerts #36–#39 (
java/dereferenced-value-may-be-null,ServerEndPoint), #34 (java/input-resource-leak,SeparatedValuesFile) and, in a follow-up commit, #23 / #24 (java/missing-override-annotation, the twoonMessagehandlers inServerEndPoint).ServerEndPoint— a real bug behind the warningsechoTextMessage/echoBinaryMessagewait up to ~5 s forsession_upstreamand then dereference it without a check, relying on theNullPointerExceptionto reach thecatch (Throwable)that closes the client. Writing a test for that path showed the close never happens:jakarta.websocket.CloseReasonthrowsIllegalArgumentExceptionfor reason phrases longer than 123 UTF-8 bytes (checked injakarta.websocket-api-2.0.0), and the helpful NPE message of Java 14+ (Cannot invoke "jakarta.websocket.Session.getBasicRemote()" because "this.session_upstream" is null) is longer than that.new CloseReason(...)is evaluated as the argument ofclose(), the exception is swallowed by the innercatch (Throwable e1) {}, and the client connection is left open with the message silently dropped. The same applies to any exception with a long message in all sevennew CloseReason(..., "…: " + e)sites in the class.This holds wherever
CloseReasonenforces the limit: the Eclipsejakarta.websocket-api(the unit-test classpath), Jetty 11+, Tyrus. Tomcat's ownCloseReasonhas no length check andWsSessiontruncates the phrase itself, so on theopenig-dockerimage (tomcat:11-jre25) the client was already closed and only the reason phrase is affected — except for a phrase over 123 bytes with supplementary characters that is short in UTF-16 units, whereWsSession.appendCloseReasonWithTruncationthrowsStringIndexOutOfBoundsExceptionand no close frame goes out.Changes:
upstream(): the wait loop moved into a method that reads the volatile field once per iteration and fails withIOException("upstream not connected")when the upstream is still missing/closed.closeReason(code, phrase): a phrase over the 123-byte limit is cut to 120 bytes and ends with…(U+2026), the marker Tomcat'sWsSessionputs on its own cut, so a truncated reason reads the same on every container. The cut never splits a multi-byte character or a surrogate pair. EveryCloseReasonin the class is now built through it.closeUpstream(reason)/closeAbnormally(client, phrase): null-safe close and reset of the upstream, shared by both@OnMessagehandlers,@OnCloseand@OnError. The upstream-sideMessageHandlers still close their ownonOpensession parameter directly: it is never null, and the field is assigned only afterconnectToServer()returns, while the handlers are registered inonOpen.@OnMessagehandlers callauthorize()before waiting for the upstream: a revoked client is closed withaccess revoked, as before, and no longer waits ~5 s for a missing upstream first.@OnErrorcloses the upstream before the client. On Tomcatclose()runs@OnCloseon the calling thread, soend()used to close the upstream withNORMAL_CLOSUREfirst and theclient error: …reason never reached it. Now the upstream getsCLOSED_ABNORMALLY(sent by Tomcat as 1002), and itsonClosecloses the client with the same reason.session_upstreamisvolatile(written in@OnOpen, read from@OnMessageon other threads).Tests (
ServerEndPointTest, new):CLOSED_ABNORMALLY/"upstream not connected". Before the fix both failed withWanted but not invoked: session.close(...)— zero interactions with the client session.shouldCloseStaleUpstreamWhenItIsNotOpen: an upstream that is set but no longer open → client closed with"upstream not connected", the stale session closed and the field cleared.shouldTruncateLongUpstreamErrorWhenClosingClient: the upstream fails with a 200-charIOException→ the client close phrase is cut to 123 bytes ending in…, and the sameCloseReasoncloses the upstream (pins thecloseAbnormally()→closeReason()routing; no wait).shouldCloseRevokedClientWithoutWaitingForUpstreamOnTextMessage/…OnBinaryMessage:authorize()returns 403 and there is no upstream → client closed with exactly"client message error: access revoked"(no wait).shouldCloseUpstreamWithClientErrorOnError:client.close()runsend()as Tomcat does → the upstream is closed once, withCLOSED_ABNORMALLY/"client error: java.io.IOException: broken pipe".shouldTruncateCloseReasonPhraseToProtocolLimit: ASCII 123 untouched, 500 → 120 +…; Cyrillic 200 → 60 +…(123 bytes);"x"+ U+1F600 ×100 →"x"+ U+1F600 ×29 +…and"a"×119 + U+1F600 ×5 →"a"×119 +…(no lone high surrogate at the cut); a lone\uD83Dand""→""; short phrases untouched.The three tests that go through the wait (no upstream ×2, stale upstream) each take the real ~5 s (about +18 s on the
openig-corerun); I preferred that over adding a test-only hook to shorten the timeout in production code.ServerEndPoint— #23 / #24@Overrideadded toonMessageof the two anonymousMessageHandler.Wholeclasses (second commit,1a122ed1). Folded in here rather than into #180 to keep the file in a single open PR.SeparatedValuesFile(#34)SeparatedValuesReadernowimplements Closeable(it already hadclose()), andgetRecord()opens it in try-with-resources instead oftry/finally, with the underlyingFileInputStreamas a resource of its own so that it is closed even if theInputStreamReaderconstructor throws. Behavior-preserving; covered bySeparatedValuesFileTest(new), which reads records from a real CSV file.FileAttributesFilterTestmocksSeparatedValuesFileand does not run this code.