Skip to content

Commit 744cad6

Browse files
committed
ws check ACL Read
1 parent fd8426c commit 744cad6

File tree

7 files changed

+964
-290
lines changed

7 files changed

+964
-290
lines changed

lib/create-server.mjs

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import SolidWs from 'solid-ws'
66
import globalTunnel from 'global-tunnel-ng'
77
import debug from './debug.mjs'
88
import createApp from './create-app.mjs'
9+
import ACLChecker from './acl-checker.mjs'
10+
import url from 'url'
911

1012
function 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

package-lock.json

Lines changed: 137 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@
109109
"rimraf": "^3.0.2",
110110
"solid-auth-client": "^2.5.6",
111111
"solid-namespace": "^0.5.4",
112-
"solid-ws": "^0.4.3",
112+
"solid-server": "^6.0.0",
113+
"solid-ws": "^0.5.0-bbdbf094",
113114
"text-encoder-lite": "^2.0.0",
114115
"the-big-username-blacklist": "^1.5.2",
115116
"ulid": "^3.0.2",

0 commit comments

Comments
 (0)