Skip to content

Commit fe5ccfa

Browse files
committed
Escape LevalDB namespaces
1 parent 8af64f4 commit fe5ccfa

File tree

5 files changed

+164
-11
lines changed

5 files changed

+164
-11
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"postcss-loader": "^2.0.10",
7878
"react-test-renderer": "^16.2.0",
7979
"rimraf": "^2.6.2",
80+
"sanitize-filename": "^1.6.1",
8081
"sinon": "^4.1.4",
8182
"source-map-explorer": "^1.5.0",
8283
"webpack": "^3.10.0",

server-websocket.js

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import crypto from 'crypto';
1616
import { join, sep } from 'path';
1717
import { lstatSync, readdirSync } from 'fs';
1818

19+
import LevelDBLib from './src/server/LevelDBLib';
20+
1921
Y.extend(yWebsocketsServer, yleveldb);
2022

2123
const isDirectory = (source) => {
@@ -46,7 +48,7 @@ const server = http.createServer((req, res) => {
4648
const io = socketIo.listen(server);
4749

4850
const yInstances = {};
49-
const dirs = getDirectories('y-leveldb-databases').map(p => p.split(sep)[1]);
51+
const dirs = getDirectories('y-leveldb-databases').map(p => LevelDBLib.unescapeNamespace(p.split(sep)[1]));
5052
const metadata = dirs.reduce((accumulator, d) => Object.assign(accumulator, { [d]: {} }), {});
5153

5254
function getInstanceOfY(room) {
@@ -55,11 +57,12 @@ function getInstanceOfY(room) {
5557
db: {
5658
name: options.db,
5759
dir: 'y-leveldb-databases',
58-
namespace: room,
60+
namespace: LevelDBLib.escapeNamespace(room),
5961
},
6062
connector: {
6163
name: 'websockets-server',
62-
room,
64+
// TODO: Will be solved in future https://github.com/y-js/y-websockets-server/commit/2c8588904a334631cb6f15d8434bb97064b59583#diff-e6a5b42b2f7a26c840607370aed5301a
65+
room: encodeURIComponent(room),
6366
io,
6467
debug: !!options.debug,
6568
},
@@ -96,10 +99,12 @@ router.get('/pages', (req, res) => {
9699

97100
io.on('connection', (socket) => {
98101
const rooms = [];
99-
socket.on('joinRoom', (room) => {
102+
socket.on('joinRoom', (escapedRoom) => {
103+
// TODO: Will be solved in future https://github.com/y-js/y-websockets-server/commit/2c8588904a334631cb6f15d8434bb97064b59583#diff-e6a5b42b2f7a26c840607370aed5301a
104+
const room = decodeURIComponent(escapedRoom);
100105
console.log('User', socket.id, 'joins room:', room);
101106
socket.join(room);
102-
getInstanceOfY(room).then((y) => {
107+
getInstanceOfY(decodeURIComponent(room)).then((y) => {
103108
if (rooms.indexOf(room) === -1) {
104109
y.connector.userJoined(socket.id, 'slave');
105110
rooms.push(room);
@@ -110,10 +115,12 @@ io.on('connection', (socket) => {
110115
});
111116
socket.on('yjsEvent', (msg) => {
112117
if (msg.room != null) {
113-
getInstanceOfY(msg.room).then((y) => {
118+
// TODO: Will be solved in future https://github.com/y-js/y-websockets-server/commit/2c8588904a334631cb6f15d8434bb97064b59583#diff-e6a5b42b2f7a26c840607370aed5301a
119+
const room = decodeURIComponent(msg.room);
120+
getInstanceOfY(room).then((y) => {
114121
y.connector.receiveMessage(socket.id, msg);
115122
if (msg.type === 'update') {
116-
metadata[msg.room].modified = new Date();
123+
metadata[room].modified = new Date();
117124
}
118125
});
119126
}
@@ -127,13 +134,17 @@ io.on('connection', (socket) => {
127134
y.connector.userLeft(socket.id);
128135
rooms.splice(j, 1);
129136
metadata[room].active -= 1;
130-
io.in(room).emit('activeUser', metadata[room].active);
131-
io.in(room).emit('clientCursor', { type: 'delete', id: getSha1Hash(socket.id) });
137+
// TODO: Will be solved in future https://github.com/y-js/y-websockets-server/commit/2c8588904a334631cb6f15d8434bb97064b59583#diff-e6a5b42b2f7a26c840607370aed5301a
138+
const escapedRoom = encodeURIComponent(room);
139+
io.in(escapedRoom).emit('activeUser', metadata[room].active);
140+
io.in(escapedRoom).emit('clientCursor', { type: 'delete', id: getSha1Hash(socket.id) });
132141
}
133142
});
134143
}
135144
});
136-
socket.on('leaveRoom', (room) => {
145+
socket.on('leaveRoom', (escapedRoom) => {
146+
// TODO: Will be solved in future https://github.com/y-js/y-websockets-server/commit/2c8588904a334631cb6f15d8434bb97064b59583#diff-e6a5b42b2f7a26c840607370aed5301a
147+
const room = decodeURIComponent(escapedRoom);
137148
getInstanceOfY(room).then((y) => {
138149
const i = rooms.indexOf(room);
139150
if (i >= 0) {
@@ -146,7 +157,9 @@ io.on('connection', (socket) => {
146157
});
147158
});
148159
socket.on('clientCursor', (msg) => {
149-
if (msg.room != null) {
160+
// TODO: Will be solved in future https://github.com/y-js/y-websockets-server/commit/2c8588904a334631cb6f15d8434bb97064b59583#diff-e6a5b42b2f7a26c840607370aed5301a
161+
const room = decodeURIComponent(msg.room);
162+
if (room != null) {
150163
const msgCloned = clone(msg);
151164
msgCloned.id = getSha1Hash(socket.id);
152165
socket.to(msg.room).emit('clientCursor', msgCloned);

src/server/LevelDBLib.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const unsafeChars = /[^-_a-zA-Z0-9]/g;
2+
const windowsReserved = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
3+
const escapeSeq = /%([_0-9a-fA-F]+)%/g;
4+
5+
export default class LevelDBLib {
6+
/**
7+
* It escapes a namespace to a safe file name.
8+
* This function is injective.
9+
* @param {string} namespace namespace of Level DB
10+
* @returns {string}
11+
*/
12+
static escapeNamespace(namespace) {
13+
if (!namespace) return '%_%';
14+
if (namespace.match(windowsReserved)) {
15+
return `%_%${namespace}`;
16+
}
17+
return namespace.replace(unsafeChars, substr => `%${substr.charCodeAt(0).toString(16)}%`);
18+
}
19+
/**
20+
* Reversed function of escapeNamespace.
21+
* @param {string} escapedNamedpace file name generated by escapeNamespace
22+
* @returns {string}
23+
*/
24+
static unescapeNamespace(escapedNamedpace) {
25+
return escapedNamedpace.replace(escapeSeq, (substr, group) => {
26+
if (group === '_') return '';
27+
return String.fromCharCode(parseInt(group, 16));
28+
});
29+
}
30+
}

test/server/LevelDBLib.ava.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import test from 'ava';
2+
import sanitize from 'sanitize-filename';
3+
4+
import LevelDBLib from '../../src/server/LevelDBLib';
5+
6+
const unsafePaths = [
7+
// path traversal
8+
'/',
9+
'/etc/passwd',
10+
'.',
11+
'./..',
12+
'./dir/../..',
13+
'..',
14+
'../',
15+
// invalid filename
16+
'',
17+
' ',
18+
'last space ',
19+
'last-dot.',
20+
'con',
21+
'con.txt',
22+
'nul',
23+
// illegal chars
24+
'question?mark?char',
25+
'angle<>char',
26+
'asterisk*char',
27+
'null\0char',
28+
'newline\nchar',
29+
'tab\tchar',
30+
// path separator
31+
'dir/file',
32+
'dir\\file',
33+
];
34+
35+
/** @test {LevelDBLib.escapeNamespace} */
36+
test('escapeNamespace should escape unsafe path', t => {
37+
unsafePaths.forEach(p => {
38+
const escaped = LevelDBLib.escapeNamespace(p);
39+
t.not(p, escaped, 'should escape unsafe filename');
40+
const sanitizedEscaped = sanitize(escaped);
41+
t.is(escaped, sanitizedEscaped, 'escaped namespace must be a safe filename');
42+
});
43+
});
44+
45+
/** @test {LevelDBLib.unescapeNamespace} */
46+
test('unescapeNamespace should unescape path', t => {
47+
unsafePaths.forEach(p => {
48+
const escaped = LevelDBLib.escapeNamespace(p);
49+
const unescaped = LevelDBLib.unescapeNamespace(escaped);
50+
t.is(p, unescaped);
51+
});
52+
});
53+
54+
/** @test {LevelDBLib.escapeNamespace} */
55+
test('escapeNamespace should be injective', t => {
56+
const inputs = new Set([
57+
'',
58+
'%',
59+
'%%',
60+
'%%%',
61+
'%_%',
62+
'%%_%',
63+
'%%__%',
64+
'%1%',
65+
'%01%',
66+
]);
67+
const outputs = new Set();
68+
inputs.forEach(p => {
69+
const escaped = LevelDBLib.escapeNamespace(p);
70+
t.false(outputs.has(escaped), `not injective: ${p} -> ${escaped}`);
71+
outputs.add(escaped);
72+
});
73+
});
74+
75+
/** @test {LevelDBLib.escapeNamespace} */
76+
test('escapeNamespace should handle non-ascii chars', t => {
77+
const inputs = new Set([
78+
'ひらがな',
79+
'カタカナ',
80+
'漢字',
81+
'汉语',
82+
'한글',
83+
'عربی زبان',
84+
]);
85+
inputs.forEach(p => {
86+
const escaped = LevelDBLib.escapeNamespace(p);
87+
t.not(p, escaped, 'should escape');
88+
const sanitizedEscaped = sanitize(escaped);
89+
t.is(escaped, sanitizedEscaped, 'escaped namespace must be a safe filename');
90+
const unescaped = LevelDBLib.unescapeNamespace(escaped);
91+
t.is(p, unescaped, 'should restore to non-ascii');
92+
});
93+
});

yarn.lock

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8790,6 +8790,12 @@ [email protected]:
87908790
version "1.3.0"
87918791
resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
87928792

8793+
sanitize-filename@^1.6.1:
8794+
version "1.6.1"
8795+
resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.1.tgz#612da1c96473fa02dccda92dcd5b4ab164a6772a"
8796+
dependencies:
8797+
truncate-utf8-bytes "^1.0.0"
8798+
87938799
sax@^1.1.4, sax@~1.2.1:
87948800
version "1.2.4"
87958801
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
@@ -9714,6 +9720,12 @@ trough@^1.0.0:
97149720
version "1.0.1"
97159721
resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.1.tgz#a9fd8b0394b0ae8fff82e0633a0a36ccad5b5f86"
97169722

9723+
truncate-utf8-bytes@^1.0.0:
9724+
version "1.0.2"
9725+
resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b"
9726+
dependencies:
9727+
utf8-byte-length "^1.0.1"
9728+
97179729
97189730
version "0.0.0"
97199731
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@@ -10060,6 +10072,10 @@ [email protected]:
1006010072
bindings "~1.2.1"
1006110073
nan "~2.4.0"
1006210074

10075+
utf8-byte-length@^1.0.1:
10076+
version "1.0.4"
10077+
resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz#f45f150c4c66eee968186505ab93fcbb8ad6bf61"
10078+
1006310079
1006410080
version "2.1.0"
1006510081
resolved "https://registry.yarnpkg.com/utf8/-/utf8-2.1.0.tgz#0cfec5c8052d44a23e3aaa908104e8075f95dfd5"

0 commit comments

Comments
 (0)