@@ -6,6 +6,8 @@ import SolidWs from 'solid-ws'
66import globalTunnel from 'global-tunnel-ng'
77import debug from './debug.mjs'
88import createApp from './create-app.mjs'
9+ import ACLChecker from './acl-checker.mjs'
10+ import url from 'url'
911
1012function createServer ( argv , app ) {
1113 argv = argv || { }
@@ -96,7 +98,183 @@ function createServer (argv, app) {
9698
9799 // Setup Express app
98100 if ( ldp . live ) {
99- const solidWs = SolidWs ( server , ldpApp )
101+ // Get reference to session middleware for WebSocket upgrade parsing
102+ // The session middleware is stored on the Express app
103+ const sessionParser = ldpApp . _router . stack . find (
104+ layer => layer . name === 'session' && layer . handle
105+ ) ?. handle
106+
107+ // Extract WebID during WebSocket upgrade (before connection established)
108+ server . on ( 'upgrade' , function ( request , socket , head ) {
109+ // Create an authentication promise that resolves when auth is complete
110+ request . authPromise = ( async ( ) => {
111+ try {
112+ let webId = null
113+
114+ // Parse session cookie manually (session middleware doesn't run on upgrade)
115+ if ( sessionParser ) {
116+ // Create a minimal response object for session parser
117+ const res = {
118+ getHeader : ( ) => { } ,
119+ setHeader : ( ) => { } ,
120+ end : ( ) => { }
121+ }
122+
123+ await new Promise ( ( resolve , reject ) => {
124+ sessionParser ( request , res , ( err ) => {
125+ if ( err ) reject ( err )
126+ else resolve ( )
127+ } )
128+ } )
129+
130+ // Now req.session is available if cookie was valid
131+ if ( request . session && request . session . userId ) {
132+ webId = request . session . userId
133+ debug . ACL ( `WebSocket upgrade: Found WebID in session: ${ webId } ` )
134+ }
135+ } else {
136+ debug . ACL ( 'WebSocket upgrade: Session parser not found' )
137+ }
138+
139+ // Check Authorization header for Bearer token (alternative auth method)
140+ if ( ! webId && request . headers . authorization ) {
141+ const authHeader = request . headers . authorization
142+ debug . ACL ( `WebSocket upgrade: Found Authorization header: ${ authHeader . substring ( 0 , 20 ) } ...` )
143+ if ( authHeader . startsWith ( 'Bearer ' ) ) {
144+ try {
145+ const oidc = ldpApp . locals . oidc
146+ if ( oidc && oidc . rs ) {
147+ debug . ACL ( 'WebSocket upgrade: Attempting Bearer token authentication' )
148+ // Authenticate using OIDC Resource Server
149+ await new Promise ( ( resolve , reject ) => {
150+ const res = {
151+ getHeader : ( ) => { } ,
152+ setHeader : ( ) => { } ,
153+ status : ( ) => res ,
154+ send : ( ) => { } ,
155+ end : ( ) => { }
156+ }
157+
158+ const tokenTypesSupported = ldp . tokenTypesSupported || [ 'DPoP' , 'Bearer' ]
159+ debug . ACL ( `WebSocket upgrade: Token types supported: ${ JSON . stringify ( tokenTypesSupported ) } ` )
160+ oidc . rs . authenticate ( { tokenTypesSupported } ) ( request , res , async ( err ) => {
161+ if ( err ) {
162+ debug . ACL ( `WebSocket upgrade: Bearer token authentication failed: ${ err . message } ` )
163+ debug . ACL ( `WebSocket upgrade: Error stack: ${ err . stack } ` )
164+ // Don't reject - just continue without auth
165+ resolve ( )
166+ } else {
167+ debug . ACL ( `WebSocket upgrade: Bearer token authenticated, claims: ${ JSON . stringify ( request . claims ) } ` )
168+ // Extract WebID from token claims
169+ try {
170+ const tokenWebId = await oidc . webIdFromClaims ( request . claims )
171+ debug . ACL ( `WebSocket upgrade: webIdFromClaims returned: ${ tokenWebId } ` )
172+ if ( tokenWebId ) {
173+ webId = tokenWebId
174+ debug . ACL ( `WebSocket upgrade: Found WebID in Bearer token: ${ webId } ` )
175+ } else {
176+ debug . ACL ( 'WebSocket upgrade: webIdFromClaims returned null/undefined' )
177+ }
178+ resolve ( )
179+ } catch ( claimErr ) {
180+ debug . ACL ( `WebSocket upgrade: Could not extract WebID from claims: ${ claimErr . message } ` )
181+ debug . ACL ( `WebSocket upgrade: Claim error stack: ${ claimErr . stack } ` )
182+ // Don't reject - just continue without auth
183+ resolve ( )
184+ }
185+ }
186+ } )
187+ } )
188+ } else {
189+ debug . ACL ( 'WebSocket upgrade: OIDC not initialized, cannot verify Bearer token' )
190+ }
191+ } catch ( tokenErr ) {
192+ debug . ACL ( `WebSocket upgrade: Bearer token verification error: ${ tokenErr . message } ` )
193+ // Continue without auth
194+ }
195+ }
196+ }
197+
198+ // Store WebID on request for use in authorizeSubscription callback
199+ request . webId = webId
200+ debug . ACL ( `WebSocket upgrade: ${ webId ? 'Authenticated as ' + webId : 'Anonymous connection' } ` )
201+ debug . ACL ( `WebSocket upgrade: Set request.webId to ${ request . webId } ` )
202+ debug . ACL ( `WebSocket upgrade: request object keys: ${ Object . keys ( request ) . join ( ', ' ) } ` )
203+ } catch ( error ) {
204+ debug . ACL ( `WebSocket upgrade error: ${ error . message } ` )
205+ // Don't block the upgrade on errors, just proceed without auth
206+ request . webId = null
207+ }
208+ } ) ( )
209+ } )
210+
211+ // Authorization callback for WebSocket subscriptions
212+ // Checks ACL read permission before allowing subscription
213+ const authorizeSubscription = async function ( iri , req , callback ) {
214+ // Wait for authentication to complete
215+ if ( req . authPromise ) {
216+ try {
217+ await req . authPromise
218+ } catch ( err ) {
219+ debug . ACL ( `WebSocket authorization: auth promise failed: ${ err . message } ` )
220+ }
221+ }
222+
223+ // Extract userId from the request (set during upgrade event)
224+ const userId = req . webId || null
225+ debug . ACL ( `WebSocket authorization callback: iri=${ iri } , userId=${ userId } , req.webId=${ req . webId } ` )
226+
227+ try {
228+ // Security: Validate URL length to prevent DoS
229+ const MAX_URL_LENGTH = 2048
230+ if ( iri . length > MAX_URL_LENGTH ) {
231+ debug . ACL ( `WebSocket subscription DENIED: URL too long (${ iri . length } > ${ MAX_URL_LENGTH } )` )
232+ return callback ( null , false )
233+ }
234+
235+ const parsedUrl = url . parse ( iri )
236+ const resourcePath = decodeURIComponent ( parsedUrl . pathname )
237+ const hostname = parsedUrl . hostname || req . headers . host ?. split ( ':' ) [ 0 ]
238+ const rootUrl = ldp . resourceMapper . resolveUrl ( hostname )
239+ const resourceUrl = rootUrl + resourcePath
240+
241+ // Security: Prevent SSRF - only allow subscriptions to this server
242+ // Check if requested hostname matches the request's host
243+ const requestHost = req . headers . host ?. split ( ':' ) [ 0 ]
244+ if ( parsedUrl . hostname && parsedUrl . hostname !== requestHost && parsedUrl . hostname !== hostname ) {
245+ debug . ACL ( `WebSocket subscription DENIED: Cross-origin subscription attempt (${ parsedUrl . hostname } !== ${ requestHost } )` )
246+ return callback ( null , false )
247+ }
248+
249+ // Create a minimal request-like object for ACLChecker
250+ const pseudoReq = {
251+ hostname,
252+ path : resourcePath ,
253+ headers : req . headers ,
254+ get : ( header ) => {
255+ const headerLower = header . toLowerCase ( )
256+ return req . headers [ headerLower ]
257+ }
258+ }
259+
260+ const aclChecker = ACLChecker . createFromLDPAndRequest ( resourceUrl , ldp , pseudoReq )
261+
262+ aclChecker . can ( userId , 'Read' )
263+ . then ( allowed => {
264+ debug . ACL ( `WebSocket subscription ${ allowed ? 'ALLOWED' : 'DENIED' } for ${ iri } (user: ${ userId || 'anonymous' } )` )
265+ callback ( null , allowed )
266+ } )
267+ . catch ( err => {
268+ debug . ACL ( `WebSocket ACL check error for ${ iri } : ${ err . message } ` )
269+ callback ( null , false )
270+ } )
271+ } catch ( err ) {
272+ debug . ACL ( `WebSocket authorization error: ${ err . message } ` )
273+ callback ( null , false )
274+ }
275+ }
276+
277+ const solidWs = SolidWs ( server , ldpApp , { authorize : authorizeSubscription } )
100278 ldpApp . locals . ldp . live = solidWs . publish . bind ( solidWs )
101279 }
102280
0 commit comments