javascript-solid-server 0.0.165 → 0.0.167
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 +2 -1
- package/package.json +1 -1
- package/src/ldp/container.js +29 -1
- package/src/utils/url.js +7 -4
- package/test/container.test.js +93 -0
- package/test/url.test.js +56 -2
|
@@ -378,7 +378,8 @@
|
|
|
378
378
|
"Bash(terser losos/html.js -m -c)",
|
|
379
379
|
"Bash(terser losos/store.js -m -c)",
|
|
380
380
|
"Bash(terser losos/registry.js -m -c)",
|
|
381
|
-
"Bash(terser losos/losos.js -m -c)"
|
|
381
|
+
"Bash(terser losos/losos.js -m -c)",
|
|
382
|
+
"Bash(DATA_ROOT=/tmp/whatever node --test test/url.test.js)"
|
|
382
383
|
]
|
|
383
384
|
}
|
|
384
385
|
}
|
package/package.json
CHANGED
package/src/ldp/container.js
CHANGED
|
@@ -4,6 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
const LDP = 'http://www.w3.org/ns/ldp#';
|
|
6
6
|
|
|
7
|
+
// Dotfiles allowed to appear in ldp:contains. Anything else starting with '.'
|
|
8
|
+
// is server-internal state and must not leak into container listings — even
|
|
9
|
+
// when direct GETs are 403'd by the routing-layer dotfile guard in server.js
|
|
10
|
+
// (which rejects non-allowlisted dotpaths before WAC even runs), listing the
|
|
11
|
+
// *name* still leaks existence and gives attackers free path-fingerprinting
|
|
12
|
+
// (#350).
|
|
13
|
+
//
|
|
14
|
+
// `.well-known` is allowed because JSS exposes legitimate public resources
|
|
15
|
+
// there (e.g. the webledger registry at /.well-known/webledgers/...). At the
|
|
16
|
+
// origin root — including each pod's own origin in subdomain mode — server.js
|
|
17
|
+
// bypasses auth for `/.well-known/*` per RFC 8615. For path-based pods at
|
|
18
|
+
// `/pod/.well-known/`, the bypass does *not* apply (it matches root-relative
|
|
19
|
+
// paths only) — that case is a regular subdirectory governed by ordinary WAC,
|
|
20
|
+
// and listing the name is fine. We allow `.well-known` uniformly here so the
|
|
21
|
+
// subdomain-pod and root-pod cases work without conditional logic on the
|
|
22
|
+
// container path.
|
|
23
|
+
//
|
|
24
|
+
// Internal state that JSS currently persists under `.well-known/` (token
|
|
25
|
+
// store, pay state) shouldn't be in a public namespace at all; tracked at
|
|
26
|
+
// #358.
|
|
27
|
+
//
|
|
28
|
+
// `.acl` and `.meta` are canonical Solid per-resource sidecars.
|
|
29
|
+
const ALLOWED_DOTFILES = new Set(['.acl', '.meta', '.well-known']);
|
|
30
|
+
|
|
31
|
+
function isHiddenEntry(name) {
|
|
32
|
+
return name.startsWith('.') && !ALLOWED_DOTFILES.has(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
7
35
|
/**
|
|
8
36
|
* Generate JSON-LD representation of a container
|
|
9
37
|
* @param {string} containerUrl - Full URL of the container
|
|
@@ -14,7 +42,7 @@ export function generateContainerJsonLd(containerUrl, entries) {
|
|
|
14
42
|
// Ensure container URL ends with /
|
|
15
43
|
const baseUrl = containerUrl.endsWith('/') ? containerUrl : containerUrl + '/';
|
|
16
44
|
|
|
17
|
-
const contains = entries.map(entry => {
|
|
45
|
+
const contains = entries.filter(entry => !isHiddenEntry(entry.name)).map(entry => {
|
|
18
46
|
const childUrl = baseUrl + entry.name + (entry.isDirectory ? '/' : '');
|
|
19
47
|
const item = {
|
|
20
48
|
'@id': childUrl,
|
package/src/utils/url.js
CHANGED
|
@@ -22,8 +22,11 @@ export function updateDataRoot() {
|
|
|
22
22
|
* @throws {Error} - If path traversal is detected
|
|
23
23
|
*/
|
|
24
24
|
export function urlToPath(urlPath) {
|
|
25
|
-
// Normalize:
|
|
26
|
-
|
|
25
|
+
// Normalize: strip all leading slashes (#131 — `//foo` from bot probes
|
|
26
|
+
// would otherwise leave `/foo`, and path.resolve(root, '/foo') would
|
|
27
|
+
// treat the second arg as absolute, escape dataRoot, and trip the
|
|
28
|
+
// traversal guard with a 500 instead of resolving cleanly to a 404).
|
|
29
|
+
let normalized = urlPath.replace(/^\/+/, '');
|
|
27
30
|
normalized = decodeURIComponent(normalized);
|
|
28
31
|
|
|
29
32
|
// Security: remove path traversal attempts (multiple passes for ....// bypass)
|
|
@@ -54,8 +57,8 @@ export function urlToPath(urlPath) {
|
|
|
54
57
|
* @throws {Error} - If path traversal is detected
|
|
55
58
|
*/
|
|
56
59
|
export function urlToPathWithPod(urlPath, podName) {
|
|
57
|
-
// Normalize:
|
|
58
|
-
let normalized = urlPath.
|
|
60
|
+
// Normalize: strip all leading slashes (#131 — see urlToPath for context).
|
|
61
|
+
let normalized = urlPath.replace(/^\/+/, '');
|
|
59
62
|
normalized = decodeURIComponent(normalized);
|
|
60
63
|
|
|
61
64
|
// Security: remove path traversal attempts (multiple passes for ....// bypass)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container listing generator — dotfile-allowlist regression (#350)
|
|
3
|
+
*
|
|
4
|
+
* Server-internal sidecars (.idp/, .quota.json, .server/, future .git/, etc.)
|
|
5
|
+
* must NOT appear in ldp:contains, even though direct GETs are 403'd by the
|
|
6
|
+
* routing-layer dotfile guard in server.js (which rejects non-allowlisted
|
|
7
|
+
* dotpaths before WAC runs). Listing the names still leaks existence and
|
|
8
|
+
* gives attackers free path-fingerprinting against root-pod (--single-user)
|
|
9
|
+
* deployments.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it } from 'node:test';
|
|
13
|
+
import assert from 'node:assert';
|
|
14
|
+
import { generateContainerJsonLd } from '../src/ldp/container.js';
|
|
15
|
+
|
|
16
|
+
describe('generateContainerJsonLd dotfile filtering (#350)', () => {
|
|
17
|
+
it('emits regular entries unchanged', () => {
|
|
18
|
+
const out = generateContainerJsonLd('https://example.com/pod/', [
|
|
19
|
+
{ name: 'public', isDirectory: true },
|
|
20
|
+
{ name: 'index.html', isDirectory: false },
|
|
21
|
+
]);
|
|
22
|
+
const ids = out.contains.map(c => c['@id']);
|
|
23
|
+
assert.deepStrictEqual(ids, [
|
|
24
|
+
'https://example.com/pod/public/',
|
|
25
|
+
'https://example.com/pod/index.html',
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('keeps allowed Solid resources (.acl, .meta, .well-known)', () => {
|
|
30
|
+
// .well-known stays — JSS serves legitimate public resources there
|
|
31
|
+
// (e.g. webledger). At origin-root (including each pod's origin in
|
|
32
|
+
// subdomain mode) the routing layer bypasses auth per RFC 8615; for
|
|
33
|
+
// path-based pods at /pod/.well-known/ the bypass doesn't apply but
|
|
34
|
+
// listing the name is still fine (regular subdirectory under WAC).
|
|
35
|
+
const out = generateContainerJsonLd('https://example.com/pod/', [
|
|
36
|
+
{ name: '.acl', isDirectory: false },
|
|
37
|
+
{ name: '.meta', isDirectory: false },
|
|
38
|
+
{ name: '.well-known', isDirectory: true },
|
|
39
|
+
]);
|
|
40
|
+
const ids = out.contains.map(c => c['@id']);
|
|
41
|
+
assert.ok(ids.includes('https://example.com/pod/.acl'));
|
|
42
|
+
assert.ok(ids.includes('https://example.com/pod/.meta'));
|
|
43
|
+
assert.ok(ids.includes('https://example.com/pod/.well-known/'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('hides server-internal sidecars (.idp/, .quota.json, .server/)', () => {
|
|
47
|
+
const out = generateContainerJsonLd('https://example.com/', [
|
|
48
|
+
{ name: '.idp', isDirectory: true },
|
|
49
|
+
{ name: '.quota.json', isDirectory: false },
|
|
50
|
+
{ name: '.server', isDirectory: true },
|
|
51
|
+
]);
|
|
52
|
+
assert.deepStrictEqual(out.contains, []);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('hides server control endpoints — listing allowlist is intentionally narrower than server.js routing allowlist', () => {
|
|
56
|
+
// .pods, .notifications, .account are routed to handlers in server.js
|
|
57
|
+
// (the broader 6-entry routing allowlist) but they're NOT public
|
|
58
|
+
// linked-data resources that belong in ldp:contains. Pinning this
|
|
59
|
+
// explicitly so that adding any of these to ALLOWED_DOTFILES would
|
|
60
|
+
// fail the suite — see PR #357 review comment.
|
|
61
|
+
const out = generateContainerJsonLd('https://example.com/', [
|
|
62
|
+
{ name: '.pods', isDirectory: true },
|
|
63
|
+
{ name: '.notifications', isDirectory: true },
|
|
64
|
+
{ name: '.account', isDirectory: false },
|
|
65
|
+
]);
|
|
66
|
+
assert.deepStrictEqual(out.contains, []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('hides any unknown dotfile (default-deny on .git, .env, .DS_Store, etc.)', () => {
|
|
70
|
+
const out = generateContainerJsonLd('https://example.com/pod/', [
|
|
71
|
+
{ name: '.git', isDirectory: true },
|
|
72
|
+
{ name: '.env', isDirectory: false },
|
|
73
|
+
{ name: '.DS_Store', isDirectory: false },
|
|
74
|
+
]);
|
|
75
|
+
assert.deepStrictEqual(out.contains, []);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('keeps regular entries when mixed with hidden ones', () => {
|
|
79
|
+
const out = generateContainerJsonLd('https://example.com/', [
|
|
80
|
+
{ name: '.acl', isDirectory: false },
|
|
81
|
+
{ name: '.idp', isDirectory: true },
|
|
82
|
+
{ name: '.quota.json', isDirectory: false },
|
|
83
|
+
{ name: 'public', isDirectory: true },
|
|
84
|
+
{ name: 'profile', isDirectory: true },
|
|
85
|
+
]);
|
|
86
|
+
const ids = out.contains.map(c => c['@id']);
|
|
87
|
+
assert.deepStrictEqual(ids, [
|
|
88
|
+
'https://example.com/.acl',
|
|
89
|
+
'https://example.com/public/',
|
|
90
|
+
'https://example.com/profile/',
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/test/url.test.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Regression guard for #278 (single-user root-pod PUT → ENOTDIR).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { describe, it } from 'node:test';
|
|
8
|
+
import { describe, it, before, after } from 'node:test';
|
|
9
9
|
import assert from 'node:assert';
|
|
10
|
-
import
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { getPodName, getContentType, urlToPath, urlToPathWithPod } from '../src/utils/url.js';
|
|
11
12
|
|
|
12
13
|
describe('getPodName', () => {
|
|
13
14
|
describe('subdomain mode', () => {
|
|
@@ -126,3 +127,56 @@ describe('getContentType', () => {
|
|
|
126
127
|
});
|
|
127
128
|
});
|
|
128
129
|
});
|
|
130
|
+
|
|
131
|
+
describe('urlToPath / urlToPathWithPod (#131 — leading-slash normalization)', () => {
|
|
132
|
+
// Bot probes hammer JSS with `//foo`, `///wp-admin/...`, etc. Without
|
|
133
|
+
// multi-slash stripping these used to escape dataRoot via path.resolve
|
|
134
|
+
// (which treats `/foo` as absolute) and 500 with "Path traversal detected"
|
|
135
|
+
// instead of the expected 404.
|
|
136
|
+
|
|
137
|
+
// Save/restore DATA_ROOT — other test suites mutate process.env.DATA_ROOT
|
|
138
|
+
// (via createServer's root option) and don't always restore it. Pinning
|
|
139
|
+
// to './data' keeps the assertions stable across run order.
|
|
140
|
+
let originalDataRoot;
|
|
141
|
+
before(() => {
|
|
142
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
143
|
+
delete process.env.DATA_ROOT; // forces getDataRoot() default of './data'
|
|
144
|
+
});
|
|
145
|
+
after(() => {
|
|
146
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
147
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const dataRoot = path.resolve('./data');
|
|
151
|
+
|
|
152
|
+
describe('urlToPath', () => {
|
|
153
|
+
it('resolves a normal path inside dataRoot', () => {
|
|
154
|
+
assert.strictEqual(urlToPath('/alice/profile/card'), path.join(dataRoot, 'alice/profile/card'));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('handles double leading slash without throwing (#131)', () => {
|
|
158
|
+
assert.strictEqual(urlToPath('//about.php'), path.join(dataRoot, 'about.php'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles many leading slashes (#131)', () => {
|
|
162
|
+
assert.strictEqual(urlToPath('////wp-admin/index.php'), path.join(dataRoot, 'wp-admin/index.php'));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('still rejects real `..` traversal that escapes after normalization', () => {
|
|
166
|
+
// Security must be preserved: `/../etc/passwd` → strip leading slash →
|
|
167
|
+
// `../etc/passwd` → strip `..` → `/etc/passwd` (absolute residue) →
|
|
168
|
+
// path.resolve escapes dataRoot → guard fires.
|
|
169
|
+
assert.throws(() => urlToPath('/../etc/passwd'), /Path traversal/);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('urlToPathWithPod', () => {
|
|
174
|
+
it('resolves into the pod dir', () => {
|
|
175
|
+
assert.strictEqual(urlToPathWithPod('/profile/card', 'alice'), path.join(dataRoot, 'alice/profile/card'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('handles double leading slash (#131)', () => {
|
|
179
|
+
assert.strictEqual(urlToPathWithPod('//about.php', 'alice'), path.join(dataRoot, 'alice/about.php'));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|