Skip to content

feat: Serve PBJ gRPC services over gRPC-Web - #978

Open
Neeharika-Sompalli wants to merge 7 commits into
mainfrom
grpc-web-support
Open

Neeharika-Sompalli wants to merge 7 commits into
mainfrom
grpc-web-support

Conversation

@Neeharika-Sompalli

Copy link
Copy Markdown
Member

Description:

Adds gRPC-Web support, so browsers can call PBJ gRPC services. It also fixes native gRPC deadlines not being enforced on the server.

Why the block node needs this

The block node wants browsers to call its APIs directly (swirldslabs/entente-block-node#32). Block node plugins expose their APIs as PBJ gRPC services, which today are only reachable over native gRPC. Browsers can't speak native gRPC, because they can't read HTTP trailers.

swirldslabs/entente-block-node#50 asks PBJ to make serving the same service over gRPC-Web a one-line addition on the same port. Once this is released, the block node can register the web service next to each gRPC service in ServiceBuilderImpl.registerGrpcService, so every existing plugin gets gRPC-Web with no code change.

WebServer.builder()
        .port(8080)
        .addRouting(PbjRouting.builder().service(service))                     // native gRPC
        .routing(routing -> routing.register(PbjGrpcWebService.of(service))) // gRPC-Web
        .build()
        .start();

Changes by module

  • pbj-grpc-helidon
    • New PbjGrpcWebService, a Helidon HttpService that serves a ServiceInterface over gRPC-Web on HTTP/1.1 and HTTP/2:
      • unary and server-streaming calls; client streaming returns UNIMPLEMENTED, since gRPC-Web doesn't support it
      • binary and base64 text modes
      • compression, deadlines and the max message size
      • CORS with an origin allowlist
    • PbjProtocolSelector lets application/grpc-web* requests fall through to the HttpRouting, instead of claiming them as native gRPC.
    • PbjProtocolHandler uses the shared code moved into pbj-grpc-common; no behaviour change.
    • Fix: server-side grpc-timeout deadlines were scheduled with an absolute time instead of a delay, so they effectively never fired.
  • pbj-grpc-common: new shared classes used by both handlers: GrpcDeframer, GrpcFramer, GrpcTimeout, GrpcRequestOptions, and Base64ChunkDecoder for text mode.
  • pbj-runtime
    • RequestOptions adds the gRPC-Web content types, and isProtobuf() / isJson() recognize them, so existing generated code works unchanged.
    • ServiceInterface.Method adds type() (unary, client/server streaming, bidirectional), which defaults to UNKNOWN.
  • pbj-compiler: generated method enums now report each method's type().
  • docs: a usage-guide section on serving gRPC-Web, and an update to the architecture page.

Related issue(s):

Part of swirldslabs/entente-block-node#50.

Notes for reviewer:

  • All changes are backward compatible. Services generated by older PBJ versions still work; their methods report UNKNOWN.
  • Some unchanged lines in ServiceGenerator.java and the test GreeterService.java were reformatted by Spotless.
  • Testing:
    • New unit tests, plus end-to-end tests over HTTP/1.1 and HTTP/2, including a test where only the server can enforce the deadline.
    • pbj-core and pbj-integration-tests builds pass.
    • Also checked locally with a real gRPC-Web client (Connect for Node) and in a browser.

Checklist

  • Documented (Code comments, README, etc.)
  • Tested (unit, integration, etc.)

@Neeharika-Sompalli Neeharika-Sompalli changed the title feat: serve PBJ gRPC services over gRPC-Web feat: Serve PBJ gRPC services over gRPC-Web Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

JUnit Test Report

   545 files  + 17     545 suites  +17   48s ⏱️ +17s
 1 770 tests +194   1 766 ✅ +195   4 💤 ±0  0 ❌  - 1 
11 046 runs  +228  11 018 ✅ +229  28 💤 ±0  0 ❌  - 1 

Results for commit ec583de. ± Comparison against base commit 5623c5a.

This pull request removes 9 and adds 203 tests. Note that renamed tests count towards both.
com.hedera.pbj.grpc.client.helidon.PbjGrpcDatagramReaderTest ‑ testEnlargePartiallyFilledBuffer()
com.hedera.pbj.grpc.client.helidon.PbjGrpcDatagramReaderTest ‑ testFlipCircularBuffer()
com.hedera.pbj.grpc.helidon.PbjProtocolHandlerTest ‑ scheduleDeadlinePassesPositiveFutureNanosToDetector()
com.hedera.pbj.runtime.ProtoWriterToolsTest ‑ [1] FLOAT, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4a20f0@737ae41a, [0.1, 0.5, 100.0], 12, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4a2318@7a05cdc8
com.hedera.pbj.runtime.ProtoWriterToolsTest ‑ [1] STRING, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4ab678@e5d9fa7, [string 1, testing here, testing there], com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4ab8a0@4010f232
com.hedera.pbj.runtime.ProtoWriterToolsTest ‑ [2] BYTES, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4abac8@230dd372, [010203, ff7f0f, 42da07370bff], com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4abcf0@51c15508
com.hedera.pbj.runtime.ProtoWriterToolsTest ‑ [2] DOUBLE, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4a2540@562ec518, [0.1, 0.5, 100.0, 1.7653472635472653E240], 32, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4a2768@5c8d685e
com.hedera.pbj.runtime.ProtoWriterToolsTest ‑ [3] BOOL, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4a2990@4d0e6424, [true, false, false, true, true, true], 6, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4a2bb8@6da86e98
com.hedera.pbj.runtime.ProtoWriterToolsTest ‑ [4] ENUM, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4a2de0@476dc0af, [0, 2, 1], 3, com.hedera.pbj.runtime.ProtoWriterToolsTest$$Lambda/0x00000000ac4a3008@797ca322
com.hedera.pbj.grpc.client.helidon.PbjGrpcCallTest ‑ [1] "gzip"
com.hedera.pbj.grpc.client.helidon.PbjGrpcCallTest ‑ [1] false
com.hedera.pbj.grpc.client.helidon.PbjGrpcCallTest ‑ [2] "identity"
com.hedera.pbj.grpc.client.helidon.PbjGrpcCallTest ‑ [2] true
com.hedera.pbj.grpc.client.helidon.PbjGrpcCallTest ‑ testConstructorReportsAClosedConnectionAsAnIoFailure()
com.hedera.pbj.grpc.client.helidon.PbjGrpcDatagramReaderTest ‑ completedMessagesOwnTheirBytes()
com.hedera.pbj.grpc.client.helidon.PbjGrpcDatagramReaderTest ‑ preservesCompressionFlagAndWirePayload()
com.hedera.pbj.grpc.client.helidon.PbjGrpcDatagramReaderTest ‑ queuedAndPartialMessagesShareTheBufferLimit()
com.hedera.pbj.grpc.client.helidon.PbjGrpcDatagramReaderTest ‑ rejectsAnOversizedAnnouncedMessageBeforeAllocatingPayload()
com.hedera.pbj.grpc.client.helidon.PbjGrpcDatagramReaderTest ‑ testDrainCompleteMessageWhileNextMessageIsPartial()
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Integration Test Report

    619 files  +1      619 suites  +1   48m 5s ⏱️ + 4m 11s
163 748 tests +1  163 748 ✅ +1  0 💤 ±0  0 ❌ ±0 
163 992 runs  +1  163 992 ✅ +1  0 💤 ±0  0 ❌ ±0 

Results for commit ec583de. ± Comparison against base commit 5623c5a.

This pull request removes 2 and adds 3 tests. Note that renamed tests count towards both.
com.hedera.pbj.integration.test.ParserNeverWrapsTest ‑ [1] com.hedera.pbj.integration.test.ParserNeverWrapsTest$$Lambda/0x000000008bcf9df0@4378fbf3
com.hedera.pbj.integration.test.ParserNeverWrapsTest ‑ [2] com.hedera.pbj.integration.test.ParserNeverWrapsTest$$Lambda/0x000000008bcfa038@3137aff1
com.hedera.pbj.integration.test.ParserNeverWrapsTest ‑ [1] com.hedera.pbj.integration.test.ParserNeverWrapsTest$$Lambda/0x000000002bd17ba0@68293765
com.hedera.pbj.integration.test.ParserNeverWrapsTest ‑ [2] com.hedera.pbj.integration.test.ParserNeverWrapsTest$$Lambda/0x000000002bd1fc00@2ab9656f
com.hedera.pbj.integration.test.grpc.GrpcMethodTypeTest ‑ generatedMethodsExposeTheirDeclaredCardinality()

♻️ This comment has been updated with latest results.

@jasperpotts jasperpotts left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Security & correctness review: gRPC-Web support

The core protocol handling is sound. Framing, base64 text mode, CORS allowlisting, content-type checks and grpc-message encoding are all done correctly. However, there are three easy, unauthenticated ways to tie up server resources (DoS). I'd treat them as blockers before this is exposed to the public internet.

How this was verified: a throwaway test started a real Helidon server with PbjRouting and PbjGrpcWebService and attacked it with raw sockets over HTTP/1.1. The results are quoted below. I also checked the code against known gRPC-Web and gRPC CVE patterns.


🔴 Critical: must fix before internet exposure

C1. An empty or truncated request body hangs the call forever

PbjGrpcWebService.java:312-315, :391-394

If the body ends with zero complete messages, the worker calls requests.clientEndStreamReceived(). For unary and server-streaming calls that does nothing (Pipelines.java:535, :810), so nothing ever completes the call. With no grpc-timeout, done.await() waits forever. A partial final frame is silently dropped: readRequests never checks deframer.isMidMessage().

Probe results:

  • POST /greeter.Greeter/sayHello with Content-Length: 0 got no response at all.
  • A frame missing its last 2 bytes got no response at all.
  • An empty body sent to the server-streaming method got no response at all.

Each such request permanently pins:

  • the request thread
  • the socket, which stays in CLOSE_WAIT after the client leaves, so file descriptors leak
  • the Helidon connection, and the service pipeline

One attacker with a loop of tiny POSTs can use up the server's file descriptors and memory.

Fix:

  • For UNARY and SERVER_STREAMING, require exactly one request message. At EOF, zero messages or a partial frame should fail with INTERNAL or INVALID_ARGUMENT, as grpc-java does ("No request" / "Too many requests").
  • Always fail on isMidMessage() at EOF.

C2. One request can start thousands of server-streaming calls

PbjGrpcWebService.java:367, Pipelines.java:791-807

For server-streaming, every request message calls method.apply(...) again, all writing to the same response. readRequests keeps reading until EOF because the call isn't complete.

Probe result: one HTTP request carrying 1,000 empty 5-byte frames (5 KB in total) made 1,000 calls to sayHelloStreamReply. For block-node subscriptions, every call starts a long-lived session.

Fix: the same one-message rule as C1, enforced in the web service, which already knows MethodType. The native handler has the same flaw, but there it's only reachable by native gRPC clients. This PR puts it in reach of any browser.

C3. Compressed requests are decompressed without any size limit

GrpcDeframer.java:118, GrpcCompression.java:74-86, ZstdGrpcTransformer.decompress

maxMessageSizeBytes is checked against the compressed size only. Gzip and zstd decompress into an unbounded ByteArrayOutputStream.

Probe result: an 8,175-byte gzip request, under the 10 KB limit, reached the service as 8,388,608 bytes, about 1000× larger. Zstd can compress zeros at far higher ratios. Many concurrent requests like this will run the JVM out of memory.

Other gRPC libraries have shipped CVEs for exactly this pattern (size limit checked only after decompressing). The bug predates this PR on the native side, but this PR exposes it to the internet.

Fix: pass a limit into Decompressor, or wrap the stream and count bytes, then abort with RESOURCE_EXHAUSTED once output exceeds maxMessageSizeBytes.


🟠 High

H1. Only the client can bound how long a call runs

PbjGrpcWebService.java:481-488, class javadoc :93-96

The javadoc says "use a finite grpc-timeout to bound retention", but grpc-timeout is set by the client, and an attacker simply leaves it out. 99999999H (about 11,000 years) is also accepted.

  • There's no server-side maximum or default deadline.
  • There's no limit on concurrent calls.
  • Helidon's HttpService API can't see an HTTP/2 stream reset by the client. That is the same shape as the HTTP/2 "Rapid Reset" attack (CVE-2023-44487): the client resets each stream, but the server-side work carries on.

Fix: add Builder.maxTimeout(Duration) and defaultTimeout(Duration) that clamp the effective deadline, and possibly a maxConcurrentCalls limit. Document the Helidon limits that should be set (max-concurrent-requests, connection limits, idle timeouts).

H2. A client that stops reading pins threads, and the deadline can't free them

PbjGrpcWebService.java:529, :581-597

onNext writes to the socket while holding lock. Over HTTP/1 a blocking socket write has no timeout. When the deadline fires:

  1. notifyFailure() runs.
  2. finishResponse() calls lock.lock(), which can't be interrupted, so it blocks behind the stuck writer.
  3. worker.interrupt() comes after that, so it never runs.

Probe result: six seconds after a 1-second deadline, onError had been delivered, but the writer thread was still blocked in onNext. It was only freed when the client closed the socket. The lifecycle test deadlineCleanupCanReleaseABlockedAsynchronousResponseWrite only passes because its fake stream gives way when cleanup signals it. A real socket doesn't.

Fix: use tryLock(timeout) in finishResponse. On expiry, close or abort the connection instead of waiting for the writer. Add a real slow-reader test on raw sockets.

H3. A 5-byte header makes the server allocate the full max message size

GrpcDeframer.java:186

new byte[length] is allocated as soon as the 5-byte prefix arrives. With the block node's larger maxMessageSizeBytes, each stalled connection that sent only a prefix holds a full-size buffer, and with H1 there's no server-side limit on how long. For example, 1,000 connections × 4 MB = 4 GB of heap.

Fix: allocate based on the bytes actually received, e.g. grow in 64 KB steps.


🟡 Medium: correctness and integration

M1. Whether zstd works depends on whether native gRPC traffic has arrived yet

PbjProtocolHandler.java:73

The zstd compressor is registered in PbjProtocolHandler's static initializer. That class is only loaded on the first native gRPC request.

Probe result: on a fresh server, a web request with grpc-encoding: zstd returned grpc-status: 12 Decompressor is not installed. After any native call, the same request works.

Fix: register zstd somewhere loaded by both paths, e.g. a static block in PbjGrpcWebService or a shared registry init in pbj-grpc-common.

M2. The native gRPC deadline fix changes production behaviour, but the PR says "no behaviour change"

PbjProtocolSelector.java:50-52

The fix is correct: deadlines were previously scheduled with an absolute nanoTime value, so they effectively never fired. But native gRPC deadlines now actually fire, including on long-lived consensus-node and block-node streams where clients set grpc-timeout. The PR text says PbjProtocolHandler has "no behaviour change", which is wrong for this and for the new writeLock. Please call it out in the release notes and test it against the consensus node.

M3. onError and onNext can reach the service at the same time

PbjGrpcWebService.java:350-358

notifyFailure calls pipeline.onError() on a new virtual thread while the worker may still be inside requests.onNext(). The Flow / Reactive Streams rules require signals to a subscriber to be serial. Service pipelines, including generated Pipelines, have no synchronization. The native deadline path now does the same thing (PbjProtocolHandler.deadlineExceeded).

M4. catch (Throwable) swallows OutOfMemoryError

PbjGrpcWebService.java:316

catch (final Throwable e) in dispatch turns OutOfMemoryError (e.g. from C3) and other Errors into ordinary gRPC failures. Catch Exception, or rethrow VirtualMachineError.

M5. Web and native gRPC are configured and measured separately

  • The service is registered twice, and settings like maxMessageSizeBytes must be copied by hand via config(PbjConfig). If you forget, the web endpoint silently uses the 10 KB default, which differs from native gRPC.
  • Both paths use the same pbj.grpc.* metric names with no transport tag, so web traffic and its failures can't be told apart from native traffic.
  • Consider PbjRouting.builder().service(svc).grpcWeb(webConfig) or similar, so one registration serves both and shares config, plus a transport=grpc-web metric tag.

M6. All request headers, including cookies, are passed to the service as metadata

PbjGrpcWebService.java:493-495

Everything except grpc-* is passed through, including cookie, authorization, origin, host and hop-by-hop headers. That matches native gRPC, but browsers attach cookies automatically to same-origin requests. If a service ever trusts cookie metadata for auth, its CSRF safety rests entirely on the content-type check and preflight. Please document this, and consider stripping cookie unless the user opts in.


🟢 Low

  • L1. The compressed flag is accepted with grpc-encoding: identity, and the still-compressed bytes are passed straight to the parser. The spec says this should fail with INTERNAL.
  • L2. The CORS builder doesn't validate origins. A trailing slash, uppercase letters or a path silently never match. "null" can be allowlisted with no warning, and allowing the null origin is a known anti-pattern because sandboxed iframes can send it.
  • L3. Vary: Origin is only sent when the origin is allowed. With a non-wildcard allowlist it should be on every response.
  • L4. Several error messages echo attacker-controlled header values (content-type, grpc-encoding). They are percent-encoded, so this is low risk, but it's unnecessary.
  • L5. The PR description says GrpcTimeout and GrpcRequestOptions live in pbj-grpc-common. They were moved to pbj-grpc-helidon.
  • L6. In the native handler, if init() fails before the deframer is created and the client then sends DATA, data() throws a NullPointerException (null deframer, and the catch then hits the null pipeline).

✅ Checked and defended

  • CSRF via simple requests: anything other than application/grpc-web* gets a 415 before the service is opened. That means text/plain simple-request CSRF can't work, and browsers must send a preflight.
  • CORS: origins are matched exactly against a Set, with tests for lookalike origins. Allow-Credentials is never sent, so * can't be combined with credentials.
  • Header injection: UriEncoding.encodeUri was tested directly. It encodes CR, LF, %, NUL and non-ASCII, so grpc-message can't inject headers or trailers (compare Armeria's CVE-2019-16771).
  • grpc-timeout parsing: limited to 8 digits, and TimeUnit.convert saturates instead of overflowing (compare Envoy's CVE-2021-28682). The deadline comparisons handle nanoTime wraparound correctly.
  • Client frames with the 0x80 trailers flag or reserved flag bits are rejected. The declared length is checked before allocation (but see H3).
  • Base64 text mode: chunk boundaries, padding mid-stream and invalid characters are all handled, and a truncated stream is rejected.
  • HTTP/1 request smuggling: a request with a second HTTP request hidden in its unread body was not answered as a separate request, and Connection: close is set when the body isn't fully read.
  • Unary calls with multiple messages: only one call is made.

Missing test coverage, especially for attacks

Nothing currently tests:

  1. empty bodies and truncated final frames (C1)
  2. multiple request messages for server-streaming and unary calls (C2)
  3. decompression bombs, gzip and zstd (C3)
  4. a real slow-reader client on a raw socket over HTTP/1 and HTTP/2 (H2)
  5. stalled connections that sent only a length prefix (H3)
  6. a server-side maximum timeout (H1), once it exists
  7. zstd on a fresh server (M1)
  8. HTTP/2 RST_STREAM while a call is idle
  9. a wildcard-origin preflight, the null origin, and Vary on rejected responses
  10. JSON plus text mode, and Accept negotiation for JSON
  11. fuzzing GrpcDeframer and Base64ChunkDecoder with arbitrary chunk splits (the repo already has fuzz-test infrastructure in pbj-integration-tests)
  12. a regression test confirming native gRPC deadlines now fire on long-lived streams (M2)

Also worth checking

  • The build resolves Helidon 4.5.4. Check its HTTP/2 webserver against Helidon's recent security advisories before internet exposure.
  • C2, C3 and H3 also exist on the native gRPC path. They aren't regressions, but this PR makes them reachable from any browser.

Neeharika-Sompalli and others added 5 commits October 21, 2026 14:03
Signed-off-by: Neeharika-Sompalli <neeharika.sompalli@swirldslabs.com>
Signed-off-by: Neeharika-Sompalli <neeharika.sompalli@swirldslabs.com>
Signed-off-by: Neeharika-Sompalli <neeharika.sompalli@swirldslabs.com>
Signed-off-by: Neeharika-Sompalli <neeharika.sompalli@swirldslabs.com>
Helidon 4.5.6 rejects a new HTTP/2 stream on a closed connection with an
IllegalStateException ("Connection is closed") from
Http2ClientConnection.addStream, where 4.5.4 failed the socket write with an
UncheckedIOException. The Helidon bump therefore broke
GrpcCloseClientConnectionTest and changed the exception PBJ client callers
see after the server closes the connection. PbjGrpcCall now converts that
failure back to an UncheckedIOException.

Signed-off-by: Neeharika-Sompalli <neeharika.sompalli@swirldslabs.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +218 to +225
private void ensurePayloadCapacity(final int required) {
if (entityBytes == null) {
entityBytes = new byte[Math.min(entityLength, Math.max(8192, required))];
} else if (required > entityBytes.length) {
final int capacity = (int) Math.min(entityLength, Math.max((long) required, entityBytes.length * 2L));
entityBytes = Arrays.copyOf(entityBytes, capacity);
}
}

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.

I understand the reason. But I also understand that this approach may result in allocating many smaller arrays instead of one large array. I'm curious to see what our JMH benchmarks show with this change compared to the original implementation.

@Neeharika-Sompalli Neeharika-Sompalli Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I ran the comparison, and the extra allocations do add up for larger fragmented messages. For a 2 MiB message, allocation roughly doubled and throughput was about 37% lower in this run. The benefit is that if a client sends only the header, we allocate about 120 bytes instead of reserving 2 MiB upfront.

I will address this issue and update the PR.

Payload Eager ops/s Growing ops/s Eager B/op Growing B/op
256 B 30,454,583 ± 420,490 23,078,961 ± 475,446 416 424
8 KiB 1,688,339 ± 56,186 1,693,707 ± 25,732 8,352 8,360
64 KiB 293,198 ± 7,820 162,106 ± 1,929 65,696 123,096
2 MiB 7,834 ± 355 4,925 ± 127 2,097,312 4,186,409
Header only, announcing 2 MiB 34,884 ± 1,111 58,507,954 ± 657,200 2,097,280 120

deframer-eager.log
deframer-growing.log

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.

Unlike the measurements above though, the results here look a bit concerning. The throughput seems to drop by some 30% or more, and allocations almost double. The only case that benefits is a broken client that wouldn't send data. I'm not entirely sure if this case is worth optimizing for. But it's concerning that legitimate use-cases slow down significantly.

@jasperpotts , what do you think?

Signed-off-by: Neeharika-Sompalli <neeharika.sompalli@swirldslabs.com>
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.

3 participants