Add secure-ws library#22
Conversation
…er instead of JWKS-slim" This reverts commit fe359a5.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 19 changed files in this pull request and generated 13 comments.
Files not reviewed (1)
- secure-ws/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| middleware: (req: Request, res: MockResponseType, next: NextFunction) => void, | ||
| request: IncomingMessage, | ||
| socket: WebSocket | ||
| ): Promise<{ success: boolean; error?: string }> { | ||
| return new Promise((resolve) => { | ||
| const mockRes = new MockResponse(socket); | ||
| let nextCalled = false; | ||
|
|
||
| const next = (error?: any) => { | ||
| if (nextCalled) return; | ||
| nextCalled = true; | ||
|
|
||
| if (error) { | ||
| resolve({ success: false, error: error.message || 'Middleware error' }); | ||
| } else { | ||
| resolve({ success: true }); | ||
| } | ||
| }; | ||
|
|
||
| try { | ||
| middleware(request as Request, mockRes as MockResponseType, next); | ||
| } catch (error) { | ||
| console.error("Middleware exception:", error); | ||
| if (!nextCalled) { | ||
| nextCalled = true; | ||
| resolve({ success: false, error: error.message || 'Middleware exception' }); | ||
| } |
There was a problem hiding this comment.
runExpressMiddleware only resolves when next() is called or an exception is thrown. If an Express middleware ends the response (e.g., calls res.send()/res.sendStatus()) without calling next, this Promise never resolves and handleConnection will await indefinitely. Also, async/Promise-returning middleware is not awaited/rejected here, so rejections won’t be caught/resolved. Consider wiring MockResponse.send/sendStatus to resolve the Promise (or emit a completion event) and handle Promise-returning middleware by awaiting/attaching a .catch() to it.
| middleware: (req: Request, res: MockResponseType, next: NextFunction) => void, | |
| request: IncomingMessage, | |
| socket: WebSocket | |
| ): Promise<{ success: boolean; error?: string }> { | |
| return new Promise((resolve) => { | |
| const mockRes = new MockResponse(socket); | |
| let nextCalled = false; | |
| const next = (error?: any) => { | |
| if (nextCalled) return; | |
| nextCalled = true; | |
| if (error) { | |
| resolve({ success: false, error: error.message || 'Middleware error' }); | |
| } else { | |
| resolve({ success: true }); | |
| } | |
| }; | |
| try { | |
| middleware(request as Request, mockRes as MockResponseType, next); | |
| } catch (error) { | |
| console.error("Middleware exception:", error); | |
| if (!nextCalled) { | |
| nextCalled = true; | |
| resolve({ success: false, error: error.message || 'Middleware exception' }); | |
| } | |
| middleware: (req: Request, res: MockResponseType, next: NextFunction) => void | Promise<unknown>, | |
| request: IncomingMessage, | |
| socket: WebSocket | |
| ): Promise<{ success: boolean; error?: string }> { | |
| return new Promise((resolve) => { | |
| const mockRes = new MockResponse(socket); | |
| let completed = false; | |
| const resolveOnce = (result: { success: boolean; error?: string }) => { | |
| if (completed) return; | |
| completed = true; | |
| resolve(result); | |
| }; | |
| const next = (error?: any) => { | |
| if (error) { | |
| resolveOnce({ success: false, error: error.message || 'Middleware error' }); | |
| } else { | |
| resolveOnce({ success: true }); | |
| } | |
| }; | |
| const mockResWithHandlers = mockRes as MockResponseType & { | |
| send?: (...args: any[]) => any; | |
| sendStatus?: (...args: any[]) => any; | |
| }; | |
| if (typeof mockResWithHandlers.send === 'function') { | |
| const originalSend = mockResWithHandlers.send.bind(mockResWithHandlers); | |
| mockResWithHandlers.send = (...args: any[]) => { | |
| const result = originalSend(...args); | |
| resolveOnce({ success: true }); | |
| return result; | |
| }; | |
| } | |
| if (typeof mockResWithHandlers.sendStatus === 'function') { | |
| const originalSendStatus = mockResWithHandlers.sendStatus.bind(mockResWithHandlers); | |
| mockResWithHandlers.sendStatus = (...args: any[]) => { | |
| const result = originalSendStatus(...args); | |
| resolveOnce({ success: true }); | |
| return result; | |
| }; | |
| } | |
| try { | |
| const middlewareResult = middleware(request as Request, mockRes as MockResponseType, next); | |
| if ( | |
| middlewareResult && | |
| typeof (middlewareResult as Promise<unknown>).then === 'function' | |
| ) { | |
| (middlewareResult as Promise<unknown>).catch((error: any) => { | |
| console.error("Middleware exception:", error); | |
| resolveOnce({ success: false, error: error?.message || 'Middleware exception' }); | |
| }); | |
| } | |
| } catch (error: any) { | |
| console.error("Middleware exception:", error); | |
| resolveOnce({ success: false, error: error?.message || 'Middleware exception' }); |
| } | ||
|
|
||
| // Attach body as a custom property | ||
| if (httpRequest.body) { |
There was a problem hiding this comment.
if (httpRequest.body) skips valid falsy bodies (e.g., empty string, 0, false, null). If the intent is “provided vs not provided”, use an undefined check (e.g., httpRequest.body !== undefined) so the body is injected consistently.
| if (httpRequest.body) { | |
| if (httpRequest.body !== undefined) { |
| let injectedRequest = {} as IncomingMessage; | ||
|
|
||
| if (typeof ws.protocol === 'string' && ws.protocol.trim() !== '') { | ||
| const httpRequest = WSProtocolCodec.decode(ws.protocol); | ||
| injectedRequest = injectHttpRequest(request, httpRequest); | ||
| } |
There was a problem hiding this comment.
injectedRequest is initialized as an empty object and only set when ws.protocol is non-empty. When no protocol is provided, runExpressMiddleware receives an empty request object instead of the actual IncomingMessage, which will break typical auth/validation middleware expecting headers/url/etc. Initialize injectedRequest to request and then mutate it when protocol data is present.
| return async (message) => { | ||
| const request = decode(message) as IncomingMessage; | ||
|
|
||
| for (const controller of onMessage) { | ||
| await controller(request, socket); |
There was a problem hiding this comment.
decode(message) is cast to IncomingMessage, but a msgpack-decoded WebSocket message is not an HTTP IncomingMessage. This misleading type can hide real issues and encourages controllers to treat the payload like an HTTP request. Consider defining a dedicated message payload type (or unknown) and passing that through to controllers.
| return async (message) => { | |
| const request = decode(message) as IncomingMessage; | |
| for (const controller of onMessage) { | |
| await controller(request, socket); | |
| type ControllerMessage = Parameters<WSController>[0]; | |
| return async (message) => { | |
| const payload: unknown = decode(message); | |
| for (const controller of onMessage) { | |
| await controller(payload as ControllerMessage, socket); |
| @@ -0,0 +1,6 @@ | |||
| // types/mock-response.ts | |||
| export interface MockResponse { | |||
There was a problem hiding this comment.
MockResponse includes a locals property, but the exported MockResponse interface doesn’t declare it. This prevents middleware from using res.locals in a type-safe way and makes the mock/contract inconsistent. Consider adding locals (and any other supported Express-like fields) to the interface, or removing it from the implementation if it isn’t intended to be supported.
| export interface MockResponse { | |
| export interface MockResponse { | |
| locals: Record<string, any>; |
| - filename: aws-credentials-utils/store-credentials.sh | ||
| checksum: 784aec6e80314be796af73887ba630fbaf0e116cc4d11bd5cba787a20f9c4bbb | ||
| - filename: aws-ssm/package-lock.json | ||
| checksum: 4cf91061e42ed9b1aafb17bdf68c9b4b24a7f86b191133ab4095fa635f8f01a0 | ||
| - filename: jwks-slim/README.md | ||
| checksum: a1954df51e49fc6a09d7fe04bac198fe60a4fb39b2569180475a749a72df472d | ||
| - filename: jwks-slim/package-lock.json | ||
| checksum: d014d548f9f2997cbe1d7c4f7ec312f8c634e88788bc453ccc606ea70e081a77 | ||
| - filename: jwks-slim/tests/cache.test.js | ||
| checksum: 716f25000789d7ca18adfe07f968bae6eb2fbfa1c3d05e9e92897b9597ac5f17 | ||
| - filename: jwks-slim/tests/index.test.js | ||
| checksum: 992a5c1f6c254fd344ae52955aa0c1c22a59e0472fe836b0450ff499255ef6f8 | ||
| - filename: jwks-slim/tests/keys.js | ||
| checksum: 207c0d07dea4c069883822b2b268d3b34c8b18bdfd645b8aa3b2be20f280c270 | ||
| - filename: jwks-slim/tests/mock.js | ||
| checksum: 0d2fd2ec4847acda384c398e04e8aab9a615a8599b0a06d685e7fb100e71537c | ||
| - filename: secure-ws/mocks/express-response.ts | ||
| checksum: 7774e72616894119c419f7ad86a3da579036b0391fd208766bcdcb96401b8d2c | ||
| - filename: websocket-provider/package-lock.json | ||
| checksum: 605a2d92b91f08ee577d3b1e7ed38d27eef43573a044608c22466f05ab6eaff7 |
There was a problem hiding this comment.
This YAML structure no longer nests the ignore list under fileignoreconfig (the - filename entries are at the top level). That likely breaks talisman’s config parsing. Indent the list items under fileignoreconfig: (e.g., fileignoreconfig:\n - filename: ...).
| - filename: aws-credentials-utils/store-credentials.sh | |
| checksum: 784aec6e80314be796af73887ba630fbaf0e116cc4d11bd5cba787a20f9c4bbb | |
| - filename: aws-ssm/package-lock.json | |
| checksum: 4cf91061e42ed9b1aafb17bdf68c9b4b24a7f86b191133ab4095fa635f8f01a0 | |
| - filename: jwks-slim/README.md | |
| checksum: a1954df51e49fc6a09d7fe04bac198fe60a4fb39b2569180475a749a72df472d | |
| - filename: jwks-slim/package-lock.json | |
| checksum: d014d548f9f2997cbe1d7c4f7ec312f8c634e88788bc453ccc606ea70e081a77 | |
| - filename: jwks-slim/tests/cache.test.js | |
| checksum: 716f25000789d7ca18adfe07f968bae6eb2fbfa1c3d05e9e92897b9597ac5f17 | |
| - filename: jwks-slim/tests/index.test.js | |
| checksum: 992a5c1f6c254fd344ae52955aa0c1c22a59e0472fe836b0450ff499255ef6f8 | |
| - filename: jwks-slim/tests/keys.js | |
| checksum: 207c0d07dea4c069883822b2b268d3b34c8b18bdfd645b8aa3b2be20f280c270 | |
| - filename: jwks-slim/tests/mock.js | |
| checksum: 0d2fd2ec4847acda384c398e04e8aab9a615a8599b0a06d685e7fb100e71537c | |
| - filename: secure-ws/mocks/express-response.ts | |
| checksum: 7774e72616894119c419f7ad86a3da579036b0391fd208766bcdcb96401b8d2c | |
| - filename: websocket-provider/package-lock.json | |
| checksum: 605a2d92b91f08ee577d3b1e7ed38d27eef43573a044608c22466f05ab6eaff7 | |
| - filename: aws-credentials-utils/store-credentials.sh | |
| checksum: 784aec6e80314be796af73887ba630fbaf0e116cc4d11bd5cba787a20f9c4bbb | |
| - filename: aws-ssm/package-lock.json | |
| checksum: 4cf91061e42ed9b1aafb17bdf68c9b4b24a7f86b191133ab4095fa635f8f01a0 | |
| - filename: jwks-slim/README.md | |
| checksum: a1954df51e49fc6a09d7fe04bac198fe60a4fb39b2569180475a749a72df472d | |
| - filename: jwks-slim/package-lock.json | |
| checksum: d014d548f9f2997cbe1d7c4f7ec312f8c634e88788bc453ccc606ea70e081a77 | |
| - filename: jwks-slim/tests/cache.test.js | |
| checksum: 716f25000789d7ca18adfe07f968bae6eb2fbfa1c3d05e9e92897b9597ac5f17 | |
| - filename: jwks-slim/tests/index.test.js | |
| checksum: 992a5c1f6c254fd344ae52955aa0c1c22a59e0472fe836b0450ff499255ef6f8 | |
| - filename: jwks-slim/tests/keys.js | |
| checksum: 207c0d07dea4c069883822b2b268d3b34c8b18bdfd645b8aa3b2be20f280c270 | |
| - filename: jwks-slim/tests/mock.js | |
| checksum: 0d2fd2ec4847acda384c398e04e8aab9a615a8599b0a06d685e7fb100e71537c | |
| - filename: secure-ws/mocks/express-response.ts | |
| checksum: 7774e72616894119c419f7ad86a3da579036b0391fd208766bcdcb96401b8d2c | |
| - filename: websocket-provider/package-lock.json | |
| checksum: 605a2d92b91f08ee577d3b1e7ed38d27eef43573a044608c22466f05ab6eaff7 |
| - name: Generate token | ||
| if: ${{ github.ref_name == 'main'}} | ||
| id: generate_token | ||
| uses: tibdex/github-app-token@v1 | ||
| with: | ||
| app_id: ${{ vars.FUNDABOT_APP_ID }} | ||
| private_key: ${{ secrets.FUNDABOT_PRIVATE_KEY }} | ||
|
|
||
| - uses: actions/setup-node@v3 | ||
| with: | ||
| node-version: 18 | ||
|
|
||
| - uses: actions/checkout@v3 | ||
| with: | ||
| token: ${{ github.ref_name == 'main' && steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Install dependencies | ||
| run: npm ci | ||
|
|
There was a problem hiding this comment.
On main, you generate an app token for checkout, but the later Push changes step uses ${{ env.GITHUB_TOKEN }} which is set to the default secrets.GITHUB_TOKEN, not the generated app token. If main is protected, this push is likely to fail. Consider passing the same conditional token used in checkout (or set env.GITHUB_TOKEN from steps.generate_token.outputs.token on main).
| @@ -0,0 +1,3 @@ | |||
| import { WebSocket } from 'ws'; | |||
|
|
|||
| export type WSController = (message: any, socket: WebSocket) => any; No newline at end of file | |||
There was a problem hiding this comment.
WSController uses any for both the message parameter and return type, which leaks any into consumers and defeats type safety across the public API surface. Consider using unknown for inputs and a more specific return type (often void | Promise<void>) so consumers can model their message shapes explicitly.
| export type WSController = (message: any, socket: WebSocket) => any; | |
| export type WSController = (message: unknown, socket: WebSocket) => void | Promise<void>; |
… add locals property to MockResponse interface
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 19 changed files in this pull request and generated 6 comments.
Files not reviewed (1)
- secure-ws/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { WebSocket } from 'ws'; | ||
| import { MockResponse } from '../mocks/express-response'; | ||
| import { MockResponse as MockResponseType } from '../types/mock-response'; | ||
| import { NextFunction, Request, Response } from 'express'; |
There was a problem hiding this comment.
Response is imported from express but never used in this module. Removing unused imports avoids lint/build failures in projects that enforce noUnusedLocals/noUnusedParameters.
| import { NextFunction, Request, Response } from 'express'; | |
| import { NextFunction, Request } from 'express'; |
| // Merge headers | ||
| if (httpRequest.headers) { | ||
| for (const [key, value] of Object.entries(httpRequest.headers)) { | ||
| request.headers[key.toLowerCase()] = value; |
There was a problem hiding this comment.
injectHttpRequest unconditionally overwrites existing request headers with values provided via ws.protocol. This lets a client spoof/override headers that may already be set by upstream infrastructure (e.g. x-forwarded-for, cookie, etc.). Consider whitelisting which headers can be injected, namespacing them, or only setting a header if it is not already present.
| // Merge headers | |
| if (httpRequest.headers) { | |
| for (const [key, value] of Object.entries(httpRequest.headers)) { | |
| request.headers[key.toLowerCase()] = value; | |
| // Merge headers without overwriting headers already present on the request | |
| if (httpRequest.headers) { | |
| for (const [key, value] of Object.entries(httpRequest.headers)) { | |
| const normalizedKey = key.toLowerCase(); | |
| if (request.headers[normalizedKey] === undefined) { | |
| request.headers[normalizedKey] = value; | |
| } |
| public handleUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => { | ||
| const { pathname } = new URL(request.url!, 'wss://base.url'); | ||
|
|
||
| if (this.routes[pathname]) { | ||
| this.server.handleUpgrade(request, socket, head, (ws: ClientSocket) => { | ||
|
|
||
| ws.id = `${Date.now()}-${randomUUID()}`; | ||
| this.clientSockets.set(ws.id, ws); | ||
| ws.on('close', () => { | ||
| this.clientSockets.delete(ws.id); | ||
| }); | ||
|
|
||
| this.server.emit('connection', ws, request); | ||
| }); |
There was a problem hiding this comment.
The README/PR description claims middleware runs "during the upgrade handshake", but the current flow upgrades the connection in handleUpgrade() and only runs onConnect middleware later in the 'connection' handler. If the intent is to reject unauthenticated clients before the WebSocket is established, the middleware (or an equivalent auth step) needs to run before calling server.handleUpgrade() (or use verifyClient/custom handleUpgrade gating) and respond with an HTTP error instead of upgrading first.
| ws.send(JSON.stringify({ error: res.error })); | ||
| ws.close(1000, res.error); |
There was a problem hiding this comment.
On middleware rejection, the code calls ws.send(...) and immediately ws.close(...). In ws, closing right after sending can result in the message being dropped if it hasn't been flushed yet. Consider closing in the send callback (or waiting for the socket to drain) to ensure the client reliably receives the error payload.
| ws.send(JSON.stringify({ error: res.error })); | |
| ws.close(1000, res.error); | |
| ws.send(JSON.stringify({ error: res.error }), () => { | |
| ws.close(1000, res.error); | |
| }); |
| git commit -a -m "CI: bumps secure-ws to $VERSION" -m "[skip ci]" | ||
|
|
||
| - name: Publish package to npm | ||
| run: | | ||
| npm publish --tag $BRANCH_TAG --access public | ||
|
|
There was a problem hiding this comment.
The npm publish step writes only registry=... to ~/.npmrc but does not configure the auth token line (//registry.npmjs.org/:_authToken=...). Unlike the other publish workflows in this repo, this is likely to cause npm publish to fail with an auth error. Consider matching the pattern used in .github/workflows/npm-publish.yml / npm-publish-ssm-env-util.yml by writing the _authToken entry (or using actions/setup-node with registry-url so NODE_AUTH_TOKEN is picked up).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 19 changed files in this pull request and generated 6 comments.
Files not reviewed (1)
- secure-ws/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function runExpressMiddleware( | ||
| middleware: (req: Request, res: MockResponseType, next: NextFunction) => void, | ||
| request: IncomingMessage, | ||
| socket: WebSocket |
There was a problem hiding this comment.
runExpressMiddleware accepts middleware as (req: Request, res: MockResponseType, next) => void, but routes are typed to accept ExpressMiddleware (req, res: Response, next) => any (see secure-ws/types/express-middleware.ts). This makes the public API type-inconsistent for consumers using strict type-checking. Prefer typing the adapter to accept ExpressMiddleware and cast/wrap the mock response internally to satisfy Response (or extend the mock to implement the needed subset).
| - filename: websocket-provider/package-lock.json | ||
| checksum: 605a2d92b91f08ee577d3b1e7ed38d27eef43573a044608c22466f05ab6eaff7 |
There was a problem hiding this comment.
This file is newly added, but it is already being ignored by Talisman (fileignoreconfig). Adding ignores reduces secret-detection coverage going forward; it’s safer to remove this ignore entry and fix the underlying finding (or add a narrower ignore with justification) so new changes to this file continue to be scanned.
| - filename: websocket-provider/package-lock.json | |
| checksum: 605a2d92b91f08ee577d3b1e7ed38d27eef43573a044608c22466f05ab6eaff7 |
| git config user.name "fundabot" | ||
| git commit -a -m "CI: bumps secure-ws to $VERSION" -m "[skip ci]" | ||
|
|
||
| - name: Publish package to npm |
There was a problem hiding this comment.
This workflow publishes on every push to any branch (no if: github.ref_name == 'main' guard on the publish step), and it does not set NODE_AUTH_TOKEN (unlike existing npm publish workflows in this repo). This is likely to either fail publishes due to missing auth or unintentionally publish prerelease builds from feature branches. Align with .github/workflows/npm-publish.yml by restricting publish to main (or explicitly documenting/restricting which branches are allowed) and configuring npm auth via NODE_AUTH_TOKEN/secrets.
| - name: Publish package to npm | |
| - name: Publish package to npm | |
| if: ${{ github.ref_name == 'main'}} | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} |
| "build": "tsc" | ||
| }, | ||
| "author": "The Fundwave Authors", | ||
| "license": "ISC", |
There was a problem hiding this comment.
The declared package license (ISC) doesn’t match the README’s stated license (MIT). This should be consistent to avoid legal/packaging confusion; update either package.json or README.md so they agree.
| "license": "ISC", | |
| "license": "MIT", |
| # secure-ws | ||
|
|
||
| secure-ws is a TypeScript library for secure WebSocket servers that lets you run Express-style middleware during the upgrade handshake, ensuring unauthenticated connections are never left open. |
There was a problem hiding this comment.
This repository doesn’t appear to have a .github/instructions/ directory with Copilot instructions for TypeScript/Node files (only custom-instructions/ exists). If this repo relies on GitHub Copilot instruction files, add the appropriate instruction files under .github/instructions for this new package to match the expected project layout.
| export class WebSocketProvider { | ||
| public server: WebSocketServer; | ||
| public clientSockets: Map<string, ClientSocket>; | ||
| private routes: Record<string, AddRouteParams>; | ||
|
|
||
| constructor() { | ||
| this.server = new WebSocketServer({ noServer: true }); | ||
| this.server.on('connection', this.handleConnection); | ||
| this.routes = {}; | ||
| this.clientSockets = new Map(); | ||
| } |
There was a problem hiding this comment.
This new WebSocket provider introduces several non-trivial behaviors (upgrade routing, protocol decoding, middleware execution, message decoding) but there are no automated tests added for them. Since the repo already has test coverage for other published packages (e.g., jwks-slim/tests/*), consider adding tests for at least: middleware rejection/acceptance paths, protocol injection, and message decode error handling.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 19 changed files in this pull request and generated 11 comments.
Files not reviewed (1)
- secure-ws/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (allowedOrigin === '*') return true; | ||
| if (allowedOrigin instanceof RegExp) { | ||
| return allowedOrigin.test(origin || ''); | ||
| } | ||
| const regex = new RegExp(`^${allowedOrigin.replace(/\*/g, '.*')}$`); | ||
| return regex.test(origin || ''); |
There was a problem hiding this comment.
Origin allowlist matching builds a RegExp directly from allowedOrigin without escaping regex metacharacters (e.g., .), so values like https://example.com will match unintended origins. Escape the string before replacing *, or avoid regex and use exact string comparison when no wildcard is present.
| if (allowedOrigin === '*') return true; | |
| if (allowedOrigin instanceof RegExp) { | |
| return allowedOrigin.test(origin || ''); | |
| } | |
| const regex = new RegExp(`^${allowedOrigin.replace(/\*/g, '.*')}$`); | |
| return regex.test(origin || ''); | |
| const requestOrigin = origin || ''; | |
| if (allowedOrigin === '*') return true; | |
| if (allowedOrigin instanceof RegExp) { | |
| return allowedOrigin.test(requestOrigin); | |
| } | |
| if (!allowedOrigin.includes('*')) { | |
| return allowedOrigin === requestOrigin; | |
| } | |
| const escapedAllowedOrigin = allowedOrigin | |
| .replace(/[|\\{}()[\]^$+?.]/g, '\\$&') | |
| .replace(/\*/g, '.*'); | |
| const regex = new RegExp(`^${escapedAllowedOrigin}$`); | |
| return regex.test(requestOrigin); |
|
|
||
| origins.push(...FUNDWAVE_DOMAIN_PATTERNS); | ||
|
|
There was a problem hiding this comment.
FUNDWAVE_DOMAIN_PATTERNS are always appended to the allowlist, so a consumer cannot configure origins to only their own domains. Consider making these patterns opt-in (config flag) or removing them from the generic library to avoid unintentionally broad origin access.
| try { | ||
| middleware(request as Request, mockRes as MockResponseType, next); | ||
| } catch (error) { | ||
| console.error("Middleware exception:", error); | ||
| if (!nextCalled) { | ||
| nextCalled = true; | ||
| resolve({ success: false, error: error.message || 'Middleware exception' }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Async Express middleware (declared async / returning a Promise) isn't handled: any rejection after an await won't be caught by the surrounding try/catch, and the returned Promise is ignored. Capture the return value from middleware(...) and, if it's a Promise, attach .catch(next) (and/or await it) so failures reliably reject the connection.
| import express from "express"; | ||
| import { WebSocketProvider } from 'secure-ws'; | ||
|
|
||
| const wsApp = new WebSocketProvider(); |
There was a problem hiding this comment.
README usage constructs new WebSocketProvider() without arguments, but the constructor currently requires { allowedOrigins: string }. Either update the README example to pass allowedOrigins, or make the constructor parameter optional with a secure default (ideally explicit allowlist required).
| const wsApp = new WebSocketProvider(); | |
| const wsApp = new WebSocketProvider({ | |
| allowedOrigins: "https://your-app.example.com" | |
| }); |
| default: "${{ env.BRANCH == 'main' && 'patch' || 'prerelease' }}" | ||
| PACKAGEJSON_DIR: "secure-ws" | ||
| preid: "${{ env.BRANCH }}" | ||
| skip-tag: "true" |
There was a problem hiding this comment.
Non-main branch names often contain / (e.g., feature/foo), but preid: "${{ env.BRANCH }}" and npm publish --tag $BRANCH_TAG will then use an invalid semver prerelease identifier / npm dist-tag. Sanitize github.ref_name for semver/tag safety (e.g., replace / with -).
| secure-ws is a TypeScript library for secure WebSocket servers that lets you run Express-style middleware during the upgrade handshake, ensuring unauthenticated connections are never left open. | ||
|
|
There was a problem hiding this comment.
The README/PR description says middleware runs "during the WebSocket upgrade handshake", but the current implementation runs middleware in the connection handler (after handleUpgrade completes). If handshake-time rejection is required, consider moving auth/validation to handleUpgrade (e.g., via verifyClient / manual HTTP response) or update the docs to reflect the actual behavior (connection is briefly established then closed).
| "scripts": { | ||
| "test": "echo \"Error: no test specified\" && exit 1", | ||
| "build": "tsc" | ||
| }, |
There was a problem hiding this comment.
This new library introduces significant behavior (origin validation, protocol decoding, middleware adaptation) but the package has no real test runner (npm test always exits 1) and there are no automated tests. Add unit/integration tests (similar to jwks-slim's Jest setup) to cover origin allowlisting, middleware success/failure, and protocol encode/decode roundtrips.
| if (!res.success) { | ||
| console.error("Middleware rejected connection:", res.error); | ||
| ws.send(JSON.stringify({ error: res.error })); | ||
| ws.close(1000, res.error); |
There was a problem hiding this comment.
When middleware rejects a connection, the socket is closed with code 1000 (normal closure). Use a more appropriate close code like 1008 (policy violation) so clients can reliably distinguish authentication/authorization failures from normal disconnects.
| ws.close(1000, res.error); | |
| ws.close(1008, res.error); |
| - name: Push changes | ||
| uses: ad-m/github-push-action@master | ||
| with: | ||
| github_token: ${{ env.GITHUB_TOKEN }} |
There was a problem hiding this comment.
On main you generate a GitHub App token for checkout, but the push step always uses ${{ env.GITHUB_TOKEN }} (set to secrets.GITHUB_TOKEN). If main is protected, this can prevent pushing the version-bump commit. Use steps.generate_token.outputs.token for the push step on main (or set env.GITHUB_TOKEN conditionally).
| github_token: ${{ env.GITHUB_TOKEN }} | |
| github_token: ${{ github.ref_name == 'main' && steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }} |
| git config user.name "fundabot" | ||
| git commit -a -m "CI: bumps secure-ws to $VERSION" -m "[skip ci]" | ||
|
|
||
| - name: Publish package to npm |
There was a problem hiding this comment.
npm publish requires npm authentication, but this workflow doesn't set NODE_AUTH_TOKEN / .npmrc (unlike the existing npm-publish.yml workflow). Add the npm token setup (or OIDC-based auth if that’s what you intend) so publishing doesn’t fail at runtime.
| - name: Publish package to npm | |
| - name: Publish package to npm | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} |
Add new secure-ws library
Overview
Add secure-ws library to manage websocket connections for Node.js and TypeScript projects.
Motivation
Resolution
Linked to getfundwave/discussions/issues/445
Closes getfundwave/discussions/issues/477
Features
Core WebSocket Provider:
Implements a modular WebSocket provider in
core/web-socket-provider.tswith support for custom protocols and extensibility.Protocol Codec:
Encapsulates WebSocket protocol encoding/decoding logic in
core/ws-protocol-codec.tsto send headers and body in the websocket upgrade handshakeMiddleware Support:
Includes utilities for adapting Express-style middleware to WebSocket handlers in
utils/middleware-adapter.tsandutils/inject-http-request.ts.