This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 6453a41c60 [ZEPPELIN-6646] validate websocket payloads
6453a41c60 is described below
commit 6453a41c608f30a33e5fa6c30b27d240a1bb8523
Author: gyowoo1113 <[email protected]>
AuthorDate: Sun Sep 6 21:07:08 2026 +0900
[ZEPPELIN-6646] validate websocket payloads
### What is this PR for?
WebSocket payloads are currently trusted after checking only the operation
name. `Message.receive()` maps `message.data` to its declared TypeScript type
without runtime validation, so malformed payloads can reach component handlers
unchanged.
This PR adds an OP-based runtime payload guard convention at the SDK
receive boundary. Guards are registered only for operations with a demonstrated
payload failure, rather than auditing or validating every WebSocket message up
front.
`Message.receive()` was chosen as the validation boundary because it
already has both the OP and payload before the data is passed to subscribers,
while keeping the existing interceptor contract unchanged.
`LIST_UPDATE_NOTE_JOBS` is the first guarded operation because
`ZEPPELIN-6551` demonstrated a concrete failure around partial job-removal
payloads in the Job Manager. The guard preserves valid removal stubs and
filters malformed non-removal updates before they reach subscribers. Additional
OPs can be added to the registry when a concrete runtime failure demonstrates
the need for validation.
It also catches errors thrown by `<at>MessageListener` handlers and logs
them with the corresponding OP so the failure can be attributed to the
WebSocket operation that triggered it.
`ZEPPELIN-6645` was considered as part of the validation boundary. An empty
`interpreterSettings` array is a legitimate server response when the user is
not authorized for any interpreter setting. Since the payload shape itself is
valid, the non-empty assumption belongs to the note-create handler rather than
the WebSocket validation layer, so this PR does not add a shared payload guard
for that case.
### What type of PR is it?
Bug Fix
### Todos
* [x] - Add an OP-based runtime payload guard registry
* [x] - Add validation for `LIST_UPDATE_NOTE_JOBS`
* [x] - Apply registered payload guards in `Message.receive()`
* [x] - Catch `<at>MessageListener` handler errors and log the
corresponding OP
* [x] - Add regression tests for payload validation and handler error
handling
### What is the Jira issue?
[[ZEPPELIN-6646]](https://issues.apache.org/jira/browse/ZEPPELIN-6646)
### How should this be tested?
```bash
cd zeppelin-web-angular
npm run test:shell -- message.spec.ts
npm run test:shell -- message-listener.spec.ts
```
Both test suites pass successfully.
### Screenshots (if appropriate)
N/A
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5453 from gyowoo1113/ZEPPELIN-6646-validate-websocket-payloads.
Signed-off-by: YONGJAE LEE <[email protected]>
---
.../src/message-payload-guards/index.ts | 33 +++++
.../zeppelin-sdk/src/message-payload-guards/job.ts | 47 +++++++
.../projects/zeppelin-sdk/src/message.spec.ts | 146 +++++++++++++++++++++
.../projects/zeppelin-sdk/src/message.ts | 13 ++
.../core/message-listener/message-listener.spec.ts | 90 +++++++++++++
.../app/core/message-listener/message-listener.ts | 9 +-
6 files changed, 336 insertions(+), 2 deletions(-)
diff --git
a/zeppelin-web-angular/projects/zeppelin-sdk/src/message-payload-guards/index.ts
b/zeppelin-web-angular/projects/zeppelin-sdk/src/message-payload-guards/index.ts
new file mode 100644
index 0000000000..3ab759d6ce
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-sdk/src/message-payload-guards/index.ts
@@ -0,0 +1,33 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { MessageReceiveDataTypeMap } from
'../interfaces/message-data-type-map.interface';
+import { OP } from '../interfaces/message-operator.interface';
+
+import { isListUpdateNoteJobsPayload } from './job';
+
+export type MessagePayloadGuard = (value: unknown) => boolean;
+
+type ReceiveOP = keyof MessageReceiveDataTypeMap;
+
+/**
+ * Runtime payload guards are registered only for OPs with a demonstrated
+ * payload-shape failure. Add new guards when a concrete runtime failure
+ * shows that validation is needed.
+ */
+const MESSAGE_PAYLOAD_GUARDS: Partial<Record<ReceiveOP, MessagePayloadGuard>>
= {
+ [OP.LIST_UPDATE_NOTE_JOBS]: isListUpdateNoteJobsPayload
+};
+
+export const getMessagePayloadGuard = (op: ReceiveOP): MessagePayloadGuard |
undefined => {
+ return MESSAGE_PAYLOAD_GUARDS[op];
+};
diff --git
a/zeppelin-web-angular/projects/zeppelin-sdk/src/message-payload-guards/job.ts
b/zeppelin-web-angular/projects/zeppelin-sdk/src/message-payload-guards/job.ts
new file mode 100644
index 0000000000..f3edecd093
--- /dev/null
+++
b/zeppelin-web-angular/projects/zeppelin-sdk/src/message-payload-guards/job.ts
@@ -0,0 +1,47 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const isRecord = (value: unknown): value is Record<string, unknown> => typeof
value === 'object' && value !== null;
+
+const isJobUpdate = (value: unknown): boolean => {
+ if (!isRecord(value)) {
+ return false;
+ }
+
+ if (typeof value.noteId !== 'string') {
+ return false;
+ }
+
+ if (typeof value.isRemoved !== 'boolean') {
+ return false;
+ }
+
+ if (value.isRemoved) {
+ return true;
+ }
+
+ return typeof value.noteName === 'string';
+};
+
+export const isListUpdateNoteJobsPayload = (value: unknown): boolean => {
+ if (!isRecord(value)) {
+ return false;
+ }
+
+ const noteRunningJobs = value.noteRunningJobs;
+
+ if (!isRecord(noteRunningJobs)) {
+ return false;
+ }
+
+ return Array.isArray(noteRunningJobs.jobs) &&
noteRunningJobs.jobs.every(isJobUpdate);
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.spec.ts
b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.spec.ts
new file mode 100644
index 0000000000..0bb02529cc
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.spec.ts
@@ -0,0 +1,146 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { MessageReceiveDataTypeMap } from
'./interfaces/message-data-type-map.interface';
+import { OP } from './interfaces/message-operator.interface';
+import type { WebSocketMessage } from
'./interfaces/websocket-message.interface';
+import { Message } from './message';
+
+const asReceivedMessage = (message: unknown):
WebSocketMessage<MessageReceiveDataTypeMap> =>
+ message as WebSocketMessage<MessageReceiveDataTypeMap>;
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('Message.receive', () => {
+ it('passes a non-removal job update with noteName', () => {
+ const message = new Message();
+ const listener = vi.fn();
+ const data = {
+ noteRunningJobs: {
+ jobs: [
+ {
+ noteId: 'note-1',
+ noteName: 'Test Note',
+ isRemoved: false
+ }
+ ]
+ }
+ };
+
+ message.receive(OP.LIST_UPDATE_NOTE_JOBS).subscribe(listener);
+
+ message.shortCircuit(
+ asReceivedMessage({
+ op: OP.LIST_UPDATE_NOTE_JOBS,
+ data
+ })
+ );
+
+ expect(listener).toHaveBeenCalledWith(data);
+ });
+
+ it('passes a partial removal payload without noteName', () => {
+ const message = new Message();
+ const listener = vi.fn();
+ const data = {
+ noteRunningJobs: {
+ jobs: [
+ {
+ noteId: 'note-1',
+ isRemoved: true
+ }
+ ]
+ }
+ };
+
+ message.receive(OP.LIST_UPDATE_NOTE_JOBS).subscribe(listener);
+
+ message.shortCircuit(
+ asReceivedMessage({
+ op: OP.LIST_UPDATE_NOTE_JOBS,
+ data
+ })
+ );
+
+ expect(listener).toHaveBeenCalledWith(data);
+ });
+
+ it('filters a non-removal job update without noteName and warns with the OP
only', () => {
+ const message = new Message();
+ const listener = vi.fn();
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ message.receive(OP.LIST_UPDATE_NOTE_JOBS).subscribe(listener);
+
+ message.shortCircuit(
+ asReceivedMessage({
+ op: OP.LIST_UPDATE_NOTE_JOBS,
+ data: {
+ noteRunningJobs: {
+ jobs: [
+ {
+ noteId: 'note-1',
+ isRemoved: false
+ }
+ ]
+ }
+ }
+ })
+ );
+
+ expect(listener).not.toHaveBeenCalled();
+ expect(consoleWarn).toHaveBeenCalledTimes(1);
+ expect(consoleWarn).toHaveBeenCalledWith(
+ `Dropped WebSocket OP ${String(OP.LIST_UPDATE_NOTE_JOBS)}: payload
failed validation`
+ );
+ });
+
+ it('filters a payload without a jobs array', () => {
+ const message = new Message();
+ const listener = vi.fn();
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ message.receive(OP.LIST_UPDATE_NOTE_JOBS).subscribe(listener);
+
+ message.shortCircuit(
+ asReceivedMessage({
+ op: OP.LIST_UPDATE_NOTE_JOBS,
+ data: {
+ noteRunningJobs: {}
+ }
+ })
+ );
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('keeps existing behavior for an OP without a guard', () => {
+ const message = new Message();
+ const listener = vi.fn();
+ const data = {};
+
+ message.receive(OP.NOTE).subscribe(listener);
+
+ message.shortCircuit(
+ asReceivedMessage({
+ op: OP.NOTE,
+ data
+ })
+ );
+
+ expect(listener).toHaveBeenCalledWith(data);
+ });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
index 0f070c6354..4d559a86aa 100644
--- a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
+++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
@@ -30,6 +30,8 @@ import {
} from './interfaces/message-paragraph.interface';
import { WebSocketMessage } from './interfaces/websocket-message.interface';
+import { getMessagePayloadGuard } from './message-payload-guards';
+
export type ArgumentsType<T> = T extends (...args: infer U) => void ? U :
never;
export type SendArgumentsType<K extends keyof MessageSendDataTypeMap> =
MessageSendDataTypeMap[K] extends undefined
@@ -173,8 +175,19 @@ export class Message {
}
receive<K extends keyof MessageReceiveDataTypeMap>(op: K):
Observable<Record<K, MessageReceiveDataTypeMap[K]>[K]> {
+ const guard = getMessagePayloadGuard(op);
+
return this.received$.pipe(
filter(message => message.op === op),
+ filter(message => {
+ if (!guard || guard(message.data)) {
+ return true;
+ }
+
+ // The payload can be large and carries note names, so log the OP
alone.
+ console.warn(`Dropped WebSocket OP ${String(op)}: payload failed
validation`);
+ return false;
+ }),
map(message => message.data)
) as Observable<Record<K, MessageReceiveDataTypeMap[K]>[K]>;
}
diff --git
a/zeppelin-web-angular/src/app/core/message-listener/message-listener.spec.ts
b/zeppelin-web-angular/src/app/core/message-listener/message-listener.spec.ts
new file mode 100644
index 0000000000..e12767691d
--- /dev/null
+++
b/zeppelin-web-angular/src/app/core/message-listener/message-listener.spec.ts
@@ -0,0 +1,90 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Subject } from 'rxjs';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { Message, OP, MessageReceiveDataTypeMap } from '@zeppelin/sdk';
+
+import { MessageListener, MessageListenersManager } from './message-listener';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+describe('MessageListener', () => {
+ it('logs handler errors with the OP, rethrows them, and keeps the
subscription active', () => {
+ // RxJS rethrows an error thrown inside `next` from a timer, so the
subscription itself survives.
+ vi.useFakeTimers();
+
+ const received$ = new Subject<MessageReceiveDataTypeMap[OP.NOTE]>();
+ const messageService = {
+ receive: vi.fn(() => received$.asObservable())
+ } as unknown as Message;
+
+ const error = new Error('boom');
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() =>
{});
+
+ class TestComponent extends MessageListenersManager {
+ calls = 0;
+
+ handleNote(_data: MessageReceiveDataTypeMap[OP.NOTE]): void {
+ this.calls++;
+
+ if (this.calls === 1) {
+ throw error;
+ }
+ }
+ }
+
+ const descriptor =
Object.getOwnPropertyDescriptor(TestComponent.prototype, 'handleNote')!;
+
+ MessageListener(OP.NOTE)(TestComponent.prototype, 'handleNote',
descriptor);
+
+ const component = new TestComponent(messageService);
+ const data = {} as MessageReceiveDataTypeMap[OP.NOTE];
+
+ received$.next(data);
+ received$.next(data);
+
+ expect(component.calls).toBe(2);
+ expect(consoleError).toHaveBeenCalledWith(`Failed to handle WebSocket OP
${String(OP.NOTE)}`, error);
+ expect(() => vi.runAllTimers()).toThrow(error);
+ });
+
+ it('passes received data to the handler', () => {
+ const received$ = new Subject<MessageReceiveDataTypeMap[OP.NOTE]>();
+ const messageService = {
+ receive: vi.fn(() => received$.asObservable())
+ } as unknown as Message;
+
+ class TestComponent extends MessageListenersManager {
+ receivedData?: MessageReceiveDataTypeMap[OP.NOTE];
+
+ handleNote(data: MessageReceiveDataTypeMap[OP.NOTE]): void {
+ this.receivedData = data;
+ }
+ }
+
+ const descriptor =
Object.getOwnPropertyDescriptor(TestComponent.prototype, 'handleNote')!;
+
+ MessageListener(OP.NOTE)(TestComponent.prototype, 'handleNote',
descriptor);
+
+ const component = new TestComponent(messageService);
+ const data = {} as MessageReceiveDataTypeMap[OP.NOTE];
+
+ received$.next(data);
+
+ expect(component.receivedData).toBe(data);
+ });
+});
diff --git
a/zeppelin-web-angular/src/app/core/message-listener/message-listener.ts
b/zeppelin-web-angular/src/app/core/message-listener/message-listener.ts
index 6487124ecc..1b2f0209ae 100644
--- a/zeppelin-web-angular/src/app/core/message-listener/message-listener.ts
+++ b/zeppelin-web-angular/src/app/core/message-listener/message-listener.ts
@@ -49,8 +49,13 @@ export function MessageListener<K extends keyof
MessageReceiveDataTypeMap>(op: K
this.__zeppelinMessageListeners$__.add(
this.messageService.receive(op).subscribe(data => {
- // @ts-ignore
- oldValue.apply(this, [data]);
+ try {
+ // @ts-ignore
+ oldValue.apply(this, [data]);
+ } catch (error) {
+ console.error(`Failed to handle WebSocket OP ${String(op)}`,
error);
+ throw error;
+ }
})
);
};