javascript-solid-server 0.0.47 → 0.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +56 -1
- package/README.md +45 -0
- package/SECURITY-AUDIT-2026-01-03.md +208 -0
- package/package.json +1 -1
- package/src/auth/middleware.js +44 -3
- package/src/auth/token.js +56 -28
- package/src/handlers/git.js +13 -0
- package/src/server.js +8 -0
- package/src/wac/checker.js +3 -3
- package/test/wac.test.js +10 -3
|
@@ -145,7 +145,62 @@
|
|
|
145
145
|
"Bash(if [ ! -d \"node-solid-server\" ])",
|
|
146
146
|
"Bash(then git clone --depth 1 https://github.com/nodeSolidServer/node-solid-server.git)",
|
|
147
147
|
"Bash(node test-local-nss2.js:*)",
|
|
148
|
-
"Bash(npm test)"
|
|
148
|
+
"Bash(npm test)",
|
|
149
|
+
"Bash(repos.json)",
|
|
150
|
+
"Bash(*.log)",
|
|
151
|
+
"Bash(node --check:*)",
|
|
152
|
+
"Bash(gh repo view:*)",
|
|
153
|
+
"Bash(noskey --help:*)",
|
|
154
|
+
"Bash(npx noskey --help:*)",
|
|
155
|
+
"Bash(noskey:*)",
|
|
156
|
+
"Bash(node -e:*)",
|
|
157
|
+
"Bash(node src/publish.js:*)",
|
|
158
|
+
"Bash(git remote add:*)",
|
|
159
|
+
"Bash(git fetch:*)",
|
|
160
|
+
"Bash(git rev-parse:*)",
|
|
161
|
+
"Bash(f502f06c1d7553f4b7159e8d57a1e14819dc3053b59399e080882cc8e6bb62ad )",
|
|
162
|
+
"Bash(798715377357003683b979b41c5d99c0312e6e788d789f0d5df710465483aa3e )",
|
|
163
|
+
"Bash(f810e7491da3390109ddc13a74a1fff985ba3a4735024f2b714c12d213f5ea11 )",
|
|
164
|
+
"Bash(1 )",
|
|
165
|
+
"Bash(911912000 )",
|
|
166
|
+
"Bash(4ccef8c68cf18f8f156a0bb017dfd6e0cc7ebf1672fa2d769e02e2efc700328b 1000000 )",
|
|
167
|
+
"Bash(798715377357003683b979b41c5d99c0312e6e788d789f0d5df710465483aa3e 910911000 )",
|
|
168
|
+
"Bash(~/.gitmark/faucet.txt)",
|
|
169
|
+
"Bash(blocktrails --version:*)",
|
|
170
|
+
"Bash(blocktrails --help:*)",
|
|
171
|
+
"Bash(blocktrails show:*)",
|
|
172
|
+
"Bash(git restore:*)",
|
|
173
|
+
"Bash(npm show:*)",
|
|
174
|
+
"WebFetch(domain:gitlab.com)",
|
|
175
|
+
"Bash(gh repo edit:*)",
|
|
176
|
+
"WebFetch(domain:blocktrails.github.io)",
|
|
177
|
+
"Bash(jq:*)",
|
|
178
|
+
"Bash(SOLID_SYNC=true timeout 45 node:*)",
|
|
179
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm status)",
|
|
180
|
+
"Bash(SOLID_SYNC=true ANCHOR=true timeout 8 node:*)",
|
|
181
|
+
"Bash(SOLID_SYNC=true ANCHOR=true node:*)",
|
|
182
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm diff src/watcher.js)",
|
|
183
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm add src/watcher.js)",
|
|
184
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm commit -m \"$\\(cat <<''EOF''\nAdd transfer API and HTTP 402 middleware\n\n- Add POST /transfer endpoint for user-to-user token transfers\n- Add verify402Payment middleware for token-gated APIs\n- Add GET /api/quote demo endpoint \\(costs 1 GSAT\\)\n- Add GET /balance/:did and GET /state endpoints\n- Fix anchor function to use encodeBech32m for address derivation\n- Remove OP_RETURN from anchor tx \\(state hash stored in state.json\\)\nEOF\n\\)\")",
|
|
185
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm push)",
|
|
186
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm add demo.html src/watcher.js debug.html paywall.html transfer.html)",
|
|
187
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm commit -m \"$\\(cat <<''EOF''\nAdd NIP-98 paywall, transfer, withdraw, and debug pages\n\n- Implement NIP-98 \\(kind 27235\\) for HTTP 402 authentication\n- Add paywall.html demo page showing NIP-98 flow\n- Add transfer.html for user-to-user GSAT transfers\n- Add debug.html with anchors, state, verify, withdraw, and users tabs\n- Add POST /withdraw endpoint for sats → Bitcoin address\n- Add navigation to demo.html linking all pages\nEOF\n\\)\")",
|
|
188
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm add test-amm.mjs package.json)",
|
|
189
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm commit -m \"$\\(cat <<''EOF''\nAdd AMM tests for math, signatures, and NIP-98\n\n- AMM math tests \\(calculateGsatOut, calculateSatsOut, slippage, k invariant\\)\n- Signature verification tests \\(sell, transfer, withdraw requests\\)\n- NIP-98 event creation, verification, and encoding tests\n- Update package.json with test script\nEOF\n\\)\")",
|
|
190
|
+
"Bash(SOLID_SYNC=true node src/watcher.js:*)",
|
|
191
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm add demo.html src/watcher.js)",
|
|
192
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm commit -m \"$\\(cat <<''EOF''\nAdd smart polling with manual deposit check\n\n- Change poll interval from 30s to 10 minutes\n- Add POST /check endpoint for manual deposit scan\n- Add 10-second rate limit between manual checks\n- Add \"Check Deposits\" button to demo.html\nEOF\n\\)\")",
|
|
193
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm add:*)",
|
|
194
|
+
"Bash(git -C /home/melvin/remote/github.com/blocktrails/gitmark-amm commit -m \"Use blocktrails npm package instead of local path\")",
|
|
195
|
+
"Bash(for addr in tb1pdypd4k38q4x0qz5x7hqavjhfpgt2n4tm0egggx587aafqn3wsnds8gm3yf tb1pqxmrkvuyea9v7vv323tmptjfle5tj9y6cpe5g8wqvlz6d5xmfhlqctx7py tb1p0fv2683x2j5htf9n7fkpmxsy4h7yuxmetelq2c6vp8u2zw9rhp2s5kha7v)",
|
|
196
|
+
"Bash(do echo -n \"$addr: \" curl -s \"https://mempool.space/testnet4/api/address/$addr\")",
|
|
197
|
+
"WebFetch(domain:webledgers.org)",
|
|
198
|
+
"Bash(npm pack:*)",
|
|
199
|
+
"Bash(npm info:*)",
|
|
200
|
+
"Bash(tar:*)",
|
|
201
|
+
"Bash(TEST_API=1 API_URL=https://api.solid.social node:*)",
|
|
202
|
+
"Bash(webledgers show:*)",
|
|
203
|
+
"Bash(webledgers set-balance:*)"
|
|
149
204
|
]
|
|
150
205
|
}
|
|
151
206
|
}
|
package/README.md
CHANGED
|
@@ -528,6 +528,51 @@ curl -X POST https://example.com/.pods \
|
|
|
528
528
|
| [CSS](https://github.com/CommunitySolidServer/CommunitySolidServer) | 5.8 MB | 70 | Modular, configurable |
|
|
529
529
|
| [Pivot](https://github.com/solid-contrib/pivot) | ~6 MB | 70+ | Built on CSS |
|
|
530
530
|
|
|
531
|
+
## Security
|
|
532
|
+
|
|
533
|
+
### Root ACL Required
|
|
534
|
+
|
|
535
|
+
JSS uses **restrictive mode** by default: if no ACL file exists for a resource, access is denied. This prevents unauthorized writes to unprotected containers.
|
|
536
|
+
|
|
537
|
+
**You must create a root `.acl` file** in your data directory. Example (JSON-LD format):
|
|
538
|
+
|
|
539
|
+
```json
|
|
540
|
+
{
|
|
541
|
+
"@context": {
|
|
542
|
+
"acl": "http://www.w3.org/ns/auth/acl#",
|
|
543
|
+
"foaf": "http://xmlns.com/foaf/0.1/"
|
|
544
|
+
},
|
|
545
|
+
"@graph": [
|
|
546
|
+
{
|
|
547
|
+
"@id": "#owner",
|
|
548
|
+
"@type": "acl:Authorization",
|
|
549
|
+
"acl:agent": { "@id": "https://your-domain.com/profile/card#me" },
|
|
550
|
+
"acl:accessTo": { "@id": "https://your-domain.com/" },
|
|
551
|
+
"acl:default": { "@id": "https://your-domain.com/" },
|
|
552
|
+
"acl:mode": [
|
|
553
|
+
{ "@id": "acl:Read" },
|
|
554
|
+
{ "@id": "acl:Write" },
|
|
555
|
+
{ "@id": "acl:Control" }
|
|
556
|
+
]
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
"@id": "#public",
|
|
560
|
+
"@type": "acl:Authorization",
|
|
561
|
+
"acl:agentClass": { "@id": "foaf:Agent" },
|
|
562
|
+
"acl:accessTo": { "@id": "https://your-domain.com/" },
|
|
563
|
+
"acl:default": { "@id": "https://your-domain.com/" },
|
|
564
|
+
"acl:mode": [
|
|
565
|
+
{ "@id": "acl:Read" }
|
|
566
|
+
]
|
|
567
|
+
}
|
|
568
|
+
]
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Save this as `data/.acl` (replacing `your-domain.com` with your actual domain).
|
|
573
|
+
|
|
574
|
+
See [Issue #32](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/32) for background.
|
|
575
|
+
|
|
531
576
|
## Performance
|
|
532
577
|
|
|
533
578
|
This server is designed for speed. Benchmark results on a typical development machine:
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# JSS Security Audit Report
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-01-03
|
|
4
|
+
**Auditor:** Security Review
|
|
5
|
+
**Version Audited:** 0.0.48
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Executive Summary
|
|
10
|
+
|
|
11
|
+
A security audit of JavaScriptSolidServer revealed **2 critical**, **2 high**, and **2 medium** severity vulnerabilities. The most severe allows unauthenticated users to read and write ACL (Access Control List) files, effectively bypassing all authorization.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Critical Vulnerabilities
|
|
16
|
+
|
|
17
|
+
### 1. ACL Files Bypass Authorization (CRITICAL) ⚠️
|
|
18
|
+
|
|
19
|
+
**Location:** `src/auth/middleware.js:24-28`
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
|
|
23
|
+
return { authorized: true, webId: null, wacAllow: '...', authError: null };
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Description:** The authorization middleware explicitly skips authentication and authorization checks for all requests to `.acl` files. This allows any unauthenticated user to:
|
|
28
|
+
|
|
29
|
+
1. **Read any ACL file** - Discover permission structures
|
|
30
|
+
2. **Write/Create any ACL file** - Grant themselves access to any resource
|
|
31
|
+
3. **Modify existing ACL files** - Lock out legitimate owners
|
|
32
|
+
|
|
33
|
+
**Proof of Concept:**
|
|
34
|
+
```bash
|
|
35
|
+
# Read root ACL without authentication
|
|
36
|
+
curl https://example.com/.acl
|
|
37
|
+
|
|
38
|
+
# Create malicious ACL without authentication
|
|
39
|
+
curl -X PUT https://example.com/victim/.acl \
|
|
40
|
+
-H "Content-Type: application/ld+json" \
|
|
41
|
+
-d '{"@graph":[{"@id":"#attacker","@type":"acl:Authorization","acl:agent":{"@id":"https://attacker.com/card#me"},"acl:accessTo":{"@id":"https://example.com/victim/"},"acl:mode":[{"@id":"acl:Read"},{"@id":"acl:Write"},{"@id":"acl:Control"}]}]}'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Impact:** Complete authorization bypass. Attacker can gain full control of any resource.
|
|
45
|
+
|
|
46
|
+
**CVSS Score:** 9.8 (Critical)
|
|
47
|
+
|
|
48
|
+
**Fix Required:** ACL files should require `acl:Control` permission on the resource they protect.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### 2. JWT Token Signature Not Verified (CRITICAL) ⚠️
|
|
53
|
+
|
|
54
|
+
**Location:** `src/auth/token.js:93-122`
|
|
55
|
+
|
|
56
|
+
```javascript
|
|
57
|
+
function verifyJwtToken(token) {
|
|
58
|
+
const parts = token.split('.');
|
|
59
|
+
if (parts.length !== 3) return null;
|
|
60
|
+
|
|
61
|
+
// Decode the payload (middle part) - NO SIGNATURE VERIFICATION!
|
|
62
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
63
|
+
|
|
64
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (payload.webid) {
|
|
69
|
+
return { webId: payload.webid, iat: payload.iat, exp: payload.exp };
|
|
70
|
+
}
|
|
71
|
+
// ...
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Description:** The `verifyJwtToken` function decodes JWT tokens but **never verifies the cryptographic signature**. An attacker can craft arbitrary JWT tokens with any WebID.
|
|
76
|
+
|
|
77
|
+
**Proof of Concept:**
|
|
78
|
+
```bash
|
|
79
|
+
# Forge a JWT token with attacker's WebID (signature is ignored)
|
|
80
|
+
# Header: {"alg":"RS256","typ":"JWT"}
|
|
81
|
+
# Payload: {"webid":"https://attacker.com/card#me","exp":9999999999}
|
|
82
|
+
curl https://example.com/private/ \
|
|
83
|
+
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJ3ZWJpZCI6Imh0dHBzOi8vYXR0YWNrZXIuY29tL2NhcmQjbWUiLCJleHAiOjk5OTk5OTk5OTl9.fakesig"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Impact:** Complete authentication bypass. Attacker can impersonate any user.
|
|
87
|
+
|
|
88
|
+
**CVSS Score:** 9.8 (Critical)
|
|
89
|
+
|
|
90
|
+
**Fix Required:** Verify JWT signatures against the issuer's JWKS before accepting tokens.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## High Severity Vulnerabilities
|
|
95
|
+
|
|
96
|
+
### 3. Pod Creation Without Authentication (HIGH)
|
|
97
|
+
|
|
98
|
+
**Location:** `src/server.js:203,228`
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
// Auth bypass list includes /.pods
|
|
102
|
+
if (request.url === '/.pods' || ...) {
|
|
103
|
+
return; // Skip auth
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Anyone can create pods
|
|
107
|
+
fastify.post('/.pods', handleCreatePod);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Description:** The `/.pods` endpoint allows anyone to create new pods without authentication.
|
|
111
|
+
|
|
112
|
+
**Impact:**
|
|
113
|
+
- Resource exhaustion (DoS)
|
|
114
|
+
- Username/namespace squatting
|
|
115
|
+
- Disk space exhaustion
|
|
116
|
+
|
|
117
|
+
**CVSS Score:** 7.5 (High)
|
|
118
|
+
|
|
119
|
+
**Fix Required:** Require authentication or implement rate limiting and CAPTCHA.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### 4. Default Token Secret in Production (HIGH)
|
|
124
|
+
|
|
125
|
+
**Location:** `src/auth/token.js:15`
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Description:** If `TOKEN_SECRET` environment variable is not set, a hardcoded default secret is used.
|
|
132
|
+
|
|
133
|
+
**Impact:** Tokens can be forged by anyone who knows the default secret.
|
|
134
|
+
|
|
135
|
+
**CVSS Score:** 8.1 (High)
|
|
136
|
+
|
|
137
|
+
**Fix Required:** Fail to start if TOKEN_SECRET is not set in production, or generate a random secret on first run.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Medium Severity Vulnerabilities
|
|
142
|
+
|
|
143
|
+
### 5. No Rate Limiting on Authentication Endpoints (MEDIUM)
|
|
144
|
+
|
|
145
|
+
**Location:** `src/idp/interactions.js`, `src/idp/credentials.js`
|
|
146
|
+
|
|
147
|
+
**Description:** Login, registration, and credential endpoints have no rate limiting, allowing brute force attacks.
|
|
148
|
+
|
|
149
|
+
**Impact:** Account takeover through credential stuffing or brute force.
|
|
150
|
+
|
|
151
|
+
**CVSS Score:** 5.3 (Medium)
|
|
152
|
+
|
|
153
|
+
**Fix Required:** Implement rate limiting (e.g., 5 attempts per minute per IP).
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
### 6. Information Disclosure via Error Messages (MEDIUM)
|
|
158
|
+
|
|
159
|
+
**Location:** Various handlers
|
|
160
|
+
|
|
161
|
+
**Description:** Error messages may reveal internal paths or stack traces.
|
|
162
|
+
|
|
163
|
+
**Impact:** Information leakage useful for further attacks.
|
|
164
|
+
|
|
165
|
+
**CVSS Score:** 4.3 (Medium)
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Recommendations
|
|
170
|
+
|
|
171
|
+
### Immediate Actions (Critical)
|
|
172
|
+
|
|
173
|
+
1. **Fix ACL bypass** - Require `acl:Control` permission to modify ACL files
|
|
174
|
+
2. **Verify JWT signatures** - Use `jose` library to verify against issuer JWKS
|
|
175
|
+
|
|
176
|
+
### Short-term Actions (High)
|
|
177
|
+
|
|
178
|
+
3. **Protect pod creation** - Add authentication or rate limiting
|
|
179
|
+
4. **Enforce TOKEN_SECRET** - Fail startup if not configured
|
|
180
|
+
|
|
181
|
+
### Medium-term Actions
|
|
182
|
+
|
|
183
|
+
5. **Add rate limiting** - Use `@fastify/rate-limit` plugin
|
|
184
|
+
6. **Sanitize error messages** - Remove internal details from user-facing errors
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Remediation Status
|
|
189
|
+
|
|
190
|
+
| Issue | Severity | Status | Fixed In |
|
|
191
|
+
|-------|----------|--------|----------|
|
|
192
|
+
| ACL bypass | Critical | 🟢 Fixed | v0.0.49 |
|
|
193
|
+
| JWT signature bypass | Critical | 🟢 Fixed | v0.0.49 |
|
|
194
|
+
| Unauthenticated pod creation | High | 🔴 Open | - |
|
|
195
|
+
| Default token secret | High | 🔴 Open | - |
|
|
196
|
+
| No rate limiting | Medium | 🔴 Open | - |
|
|
197
|
+
| Information disclosure | Medium | 🔴 Open | - |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Changelog
|
|
202
|
+
|
|
203
|
+
### v0.0.49 (2026-01-03)
|
|
204
|
+
- **Fixed ACL bypass**: ACL files now require `acl:Control` permission on the protected resource
|
|
205
|
+
- **Fixed JWT signature bypass**: JWTs are now verified against the IdP's JWKS before accepting
|
|
206
|
+
|
|
207
|
+
*Report generated: 2026-01-03*
|
|
208
|
+
*Last updated: 2026-01-03*
|
package/package.json
CHANGED
package/src/auth/middleware.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { getWebIdFromRequestAsync } from './token.js';
|
|
8
8
|
import { checkAccess, getRequiredMode } from '../wac/checker.js';
|
|
9
|
+
import { AccessMode } from '../wac/parser.js';
|
|
9
10
|
import * as storage from '../storage/filesystem.js';
|
|
10
11
|
import { getEffectiveUrlPath } from '../utils/url.js';
|
|
11
12
|
|
|
@@ -21,15 +22,19 @@ export async function authorize(request, reply, options = {}) {
|
|
|
21
22
|
const urlPath = request.url.split('?')[0];
|
|
22
23
|
const method = request.method;
|
|
23
24
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
|
|
25
|
+
// OPTIONS is always allowed (CORS preflight)
|
|
26
|
+
if (method === 'OPTIONS') {
|
|
27
27
|
return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"', authError: null };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Get WebID from token (supports both simple and Solid-OIDC tokens)
|
|
31
31
|
const { webId, error: authError } = await getWebIdFromRequestAsync(request);
|
|
32
32
|
|
|
33
|
+
// ACL files require special handling - check Control permission on protected resource
|
|
34
|
+
if (urlPath.endsWith('.acl')) {
|
|
35
|
+
return authorizeAclAccess(request, urlPath, method, webId, authError);
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
// Log auth failures for debugging
|
|
34
39
|
if (authError) {
|
|
35
40
|
request.log.warn({ authError, method, urlPath, hasAuth: !!request.headers.authorization }, 'Auth error');
|
|
@@ -114,3 +119,39 @@ export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError =
|
|
|
114
119
|
});
|
|
115
120
|
}
|
|
116
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Authorize access to ACL files
|
|
125
|
+
* ACL files require acl:Control permission on the resource they protect
|
|
126
|
+
*
|
|
127
|
+
* @param {object} request - Fastify request
|
|
128
|
+
* @param {string} urlPath - URL path to the ACL file
|
|
129
|
+
* @param {string} method - HTTP method
|
|
130
|
+
* @param {string|null} webId - Authenticated user's WebID
|
|
131
|
+
* @param {string|null} authError - Authentication error if any
|
|
132
|
+
* @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
|
|
133
|
+
*/
|
|
134
|
+
async function authorizeAclAccess(request, urlPath, method, webId, authError) {
|
|
135
|
+
// Determine the protected resource URL
|
|
136
|
+
// /foo/.acl protects /foo/ (container)
|
|
137
|
+
// /foo/bar.acl protects /foo/bar (resource)
|
|
138
|
+
const protectedPath = urlPath.replace(/\.acl$/, '');
|
|
139
|
+
const isProtectedContainer = protectedPath.endsWith('/');
|
|
140
|
+
const protectedUrl = `${request.protocol}://${request.hostname}${protectedPath}`;
|
|
141
|
+
|
|
142
|
+
// Get storage path for the protected resource
|
|
143
|
+
const storagePath = getEffectiveUrlPath(request).replace(/\.acl$/, '');
|
|
144
|
+
|
|
145
|
+
// All ACL operations require Control permission on the protected resource
|
|
146
|
+
// This is stricter than the Solid spec (which allows Read for reading ACLs)
|
|
147
|
+
// but simpler and more secure
|
|
148
|
+
const { allowed, wacAllow } = await checkAccess({
|
|
149
|
+
resourceUrl: protectedUrl,
|
|
150
|
+
resourcePath: storagePath,
|
|
151
|
+
isContainer: isProtectedContainer,
|
|
152
|
+
agentWebId: webId,
|
|
153
|
+
requiredMode: AccessMode.CONTROL
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return { authorized: allowed, webId, wacAllow, authError };
|
|
157
|
+
}
|
package/src/auth/token.js
CHANGED
|
@@ -37,7 +37,11 @@ export function createToken(webId, expiresIn = 3600) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Verify and decode a token (
|
|
40
|
+
* Verify and decode a simple token (2-part HMAC-signed)
|
|
41
|
+
*
|
|
42
|
+
* SECURITY: Only accepts 2-part simple tokens signed with HMAC.
|
|
43
|
+
* JWT tokens (3-part) require async verification via verifyTokenAsync().
|
|
44
|
+
*
|
|
41
45
|
* @param {string} token - The token to verify
|
|
42
46
|
* @returns {{webId: string, iat: number, exp: number} | null} Decoded payload or null
|
|
43
47
|
*/
|
|
@@ -48,9 +52,9 @@ export function verifyToken(token) {
|
|
|
48
52
|
|
|
49
53
|
const parts = token.split('.');
|
|
50
54
|
|
|
51
|
-
//
|
|
55
|
+
// JWT tokens (3 parts) require async verification - reject in sync function
|
|
52
56
|
if (parts.length === 3) {
|
|
53
|
-
return
|
|
57
|
+
return null;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
if (parts.length !== 2) {
|
|
@@ -59,13 +63,19 @@ export function verifyToken(token) {
|
|
|
59
63
|
|
|
60
64
|
const [data, signature] = parts;
|
|
61
65
|
|
|
62
|
-
// Verify signature
|
|
66
|
+
// Verify HMAC signature
|
|
63
67
|
const expectedSig = crypto
|
|
64
68
|
.createHmac('sha256', SECRET)
|
|
65
69
|
.update(data)
|
|
66
70
|
.digest('base64url');
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
// Constant-time comparison to prevent timing attacks
|
|
73
|
+
try {
|
|
74
|
+
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// If lengths don't match, timingSafeEqual throws
|
|
69
79
|
return null;
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -85,38 +95,45 @@ export function verifyToken(token) {
|
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
/**
|
|
88
|
-
* Verify a JWT token from credentials endpoint
|
|
89
|
-
*
|
|
98
|
+
* Verify a JWT token from the credentials endpoint
|
|
99
|
+
* Properly verifies signature against IdP's JWKS
|
|
100
|
+
*
|
|
90
101
|
* @param {string} token - JWT token
|
|
91
|
-
* @returns {{webId: string, iat: number, exp: number} | null}
|
|
102
|
+
* @returns {Promise<{webId: string, iat: number, exp: number} | null>}
|
|
92
103
|
*/
|
|
93
|
-
function
|
|
104
|
+
async function verifyJwtFromIdp(token) {
|
|
94
105
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Decode the payload (middle part)
|
|
101
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
106
|
+
// Dynamically import to avoid circular dependencies
|
|
107
|
+
const { getPublicJwks } = await import('../idp/keys.js');
|
|
108
|
+
const jose = await import('jose');
|
|
102
109
|
|
|
103
|
-
|
|
104
|
-
if (
|
|
110
|
+
const jwks = await getPublicJwks();
|
|
111
|
+
if (!jwks || !jwks.keys || jwks.keys.length === 0) {
|
|
105
112
|
return null;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
return { webId: payload.webid, iat: payload.iat, exp: payload.exp };
|
|
111
|
-
}
|
|
115
|
+
// Create JWKS for verification
|
|
116
|
+
const keySet = jose.createLocalJWKSet(jwks);
|
|
112
117
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
// Verify the token
|
|
119
|
+
const { payload } = await jose.jwtVerify(token, keySet, {
|
|
120
|
+
// Allow some clock skew
|
|
121
|
+
clockTolerance: 60,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Extract webid claim
|
|
125
|
+
const webId = payload.webid || payload.webId || payload.sub;
|
|
126
|
+
if (!webId) {
|
|
127
|
+
return null;
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
return
|
|
119
|
-
|
|
130
|
+
return {
|
|
131
|
+
webId,
|
|
132
|
+
iat: payload.iat,
|
|
133
|
+
exp: payload.exp
|
|
134
|
+
};
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Verification failed - invalid signature, expired, etc.
|
|
120
137
|
return null;
|
|
121
138
|
}
|
|
122
139
|
}
|
|
@@ -190,16 +207,27 @@ export async function getWebIdFromRequestAsync(request) {
|
|
|
190
207
|
return verifyNostrAuth(request);
|
|
191
208
|
}
|
|
192
209
|
|
|
193
|
-
// Fall back to
|
|
210
|
+
// Fall back to Bearer tokens
|
|
194
211
|
const token = extractToken(authHeader);
|
|
195
212
|
if (!token) {
|
|
196
213
|
return { webId: null, error: null };
|
|
197
214
|
}
|
|
198
215
|
|
|
216
|
+
// Try simple 2-part token first
|
|
199
217
|
const payload = verifyToken(token);
|
|
200
218
|
if (payload?.webId) {
|
|
201
219
|
return { webId: payload.webId, error: null };
|
|
202
220
|
}
|
|
203
221
|
|
|
222
|
+
// If 3-part JWT, verify against IdP's JWKS
|
|
223
|
+
const parts = token.split('.');
|
|
224
|
+
if (parts.length === 3) {
|
|
225
|
+
const jwtPayload = await verifyJwtFromIdp(token);
|
|
226
|
+
if (jwtPayload?.webId) {
|
|
227
|
+
return { webId: jwtPayload.webId, error: null };
|
|
228
|
+
}
|
|
229
|
+
return { webId: null, error: 'Invalid or unverifiable JWT token' };
|
|
230
|
+
}
|
|
231
|
+
|
|
204
232
|
return { webId: null, error: 'Invalid token' };
|
|
205
233
|
}
|
package/src/handlers/git.js
CHANGED
|
@@ -70,6 +70,14 @@ function findGitDir(repoPath) {
|
|
|
70
70
|
* @param {FastifyReply} reply
|
|
71
71
|
*/
|
|
72
72
|
export async function handleGit(request, reply) {
|
|
73
|
+
// Handle CORS preflight
|
|
74
|
+
if (request.method === 'OPTIONS') {
|
|
75
|
+
reply.header('Access-Control-Allow-Origin', '*');
|
|
76
|
+
reply.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
77
|
+
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
78
|
+
return reply.code(200).send();
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
const urlPath = decodeURIComponent(request.url.split('?')[0]);
|
|
74
82
|
const queryString = request.url.split('?')[1] || '';
|
|
75
83
|
|
|
@@ -178,6 +186,11 @@ export async function handleGit(request, reply) {
|
|
|
178
186
|
}
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
// Add CORS headers for browser git clients
|
|
190
|
+
reply.raw.setHeader('Access-Control-Allow-Origin', '*');
|
|
191
|
+
reply.raw.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
192
|
+
reply.raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
193
|
+
|
|
181
194
|
reply.raw.writeHead(statusCode);
|
|
182
195
|
headersSent = true;
|
|
183
196
|
reply.raw.write(bodySection);
|
package/src/server.js
CHANGED
|
@@ -74,6 +74,14 @@ export function createServer(options = {}) {
|
|
|
74
74
|
done(null, body);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
// Git content types need explicit handling (binary data)
|
|
78
|
+
fastify.addContentTypeParser('application/x-git-receive-pack-request', { parseAs: 'buffer' }, (req, body, done) => {
|
|
79
|
+
done(null, body);
|
|
80
|
+
});
|
|
81
|
+
fastify.addContentTypeParser('application/x-git-upload-pack-request', { parseAs: 'buffer' }, (req, body, done) => {
|
|
82
|
+
done(null, body);
|
|
83
|
+
});
|
|
84
|
+
|
|
77
85
|
// Attach server config to requests
|
|
78
86
|
fastify.decorateRequest('connegEnabled', null);
|
|
79
87
|
fastify.decorateRequest('notificationsEnabled', null);
|
package/src/wac/checker.js
CHANGED
|
@@ -28,9 +28,9 @@ export async function checkAccess({
|
|
|
28
28
|
const aclResult = await findApplicableAcl(resourceUrl, resourcePath, isContainer);
|
|
29
29
|
|
|
30
30
|
if (!aclResult) {
|
|
31
|
-
// No ACL found -
|
|
32
|
-
//
|
|
33
|
-
return { allowed:
|
|
31
|
+
// No ACL found - deny by default (restrictive mode)
|
|
32
|
+
// Security: Require explicit ACL for any access
|
|
33
|
+
return { allowed: false, wacAllow: 'user="", public=""' };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const { authorizations, isDefault, targetUrl: aclContainerUrl } = aclResult;
|
package/test/wac.test.js
CHANGED
|
@@ -226,15 +226,22 @@ describe('WAC Integration', () => {
|
|
|
226
226
|
|
|
227
227
|
describe('ACL Files', () => {
|
|
228
228
|
it('should create root .acl on pod creation', async () => {
|
|
229
|
-
|
|
229
|
+
// ACL files require Control permission - must be authenticated as pod owner
|
|
230
|
+
const res = await request('/wactest/.acl', { auth: 'wactest' });
|
|
230
231
|
|
|
231
232
|
assertStatus(res, 200);
|
|
232
233
|
const content = await res.json();
|
|
233
234
|
assert.ok(content['@graph'], 'Should be JSON-LD');
|
|
234
235
|
});
|
|
235
236
|
|
|
237
|
+
it('should deny unauthenticated access to .acl files', async () => {
|
|
238
|
+
// Security: ACL files must require authentication
|
|
239
|
+
const res = await request('/wactest/.acl');
|
|
240
|
+
assertStatus(res, 401);
|
|
241
|
+
});
|
|
242
|
+
|
|
236
243
|
it('should create private folder .acl', async () => {
|
|
237
|
-
const res = await request('/wactest/private/.acl');
|
|
244
|
+
const res = await request('/wactest/private/.acl', { auth: 'wactest' });
|
|
238
245
|
|
|
239
246
|
assertStatus(res, 200);
|
|
240
247
|
const content = await res.json();
|
|
@@ -248,7 +255,7 @@ describe('WAC Integration', () => {
|
|
|
248
255
|
});
|
|
249
256
|
|
|
250
257
|
it('should create inbox .acl with public append', async () => {
|
|
251
|
-
const res = await request('/wactest/inbox/.acl');
|
|
258
|
+
const res = await request('/wactest/inbox/.acl', { auth: 'wactest' });
|
|
252
259
|
|
|
253
260
|
assertStatus(res, 200);
|
|
254
261
|
const content = await res.json();
|