From 5fd8dd246626339a4a3f09327e8d5b443c09ad56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Soko=C5=82owski?= Date: Tue, 29 Sep 2020 13:19:25 +0200 Subject: [PATCH] add support for private groups at /g/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code checks for: - Presence of all three URL arguments: `a`, `a1`, `a2` - Verifies that `a`(admin key...) is 132 characters long - Verifies that `a2`(chat key...) is 169 characters long - Verifies that `a1` does not contain HTML before rendering Each case is tested, including the valid one. Signed-off-by: Jakub SokoĊ‚owski --- routes/index.js | 41 ++++++++++++++++++++++++++++++++--- tests/main.js | 57 ++++++++++++++++++++++++++++++++++++++++--------- utils/index.js | 5 +++-- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/routes/index.js b/routes/index.js index 21da0a3..9f1166d 100644 --- a/routes/index.js +++ b/routes/index.js @@ -11,7 +11,7 @@ const router = express.Router() /* Helper for generating pages */ const genPage = (req, res, options) => { /* Protection against XSS attacks */ - if (!utils.isValidUrl(options.mainTarget)) { + if (!utils.isValidUrl(options.mainTarget, useStd3ASCII=!options.whitespace)) { handleError(`Input contains HTML: ${options.mainTarget}`)(req, res) return } @@ -77,7 +77,6 @@ const handleSite = (req, res) => { const handleChatKey = (req, res) => { let chatKey = req.params[0] let uncompressedKey = chatKey - try { if (!chatKey.startsWith('0x')) { /* decompress/deserialize key */ uncompressedKey = utils.decompressKey(chatKey) @@ -123,13 +122,47 @@ const handlePublicChannel = (req, res) => { const chatName = req.params[0] genPage(req, res, { title: `Join #${chatName} in Status`, - info: `Join public channel #${chatName} on Status.`, + info: `Join public channel #${chatName} in Status.`, mainTarget: chatName, headerName: `#${chatName}`, path: req.originalUrl, }) } +/* This verifies all 3 required URL arguments are present */ +const validateGroupChatArgs = (args) => { + const requiredKeys = ['a', 'a1', 'a2'] + const argsHasKey = key => Object.keys(args).includes(key) + if (!requiredKeys.every(argsHasKey)) { + throw Error('Missing arguments!') + } + if (args.a == null || args.a.length != 132) { + throw Error('Admin public key invalid!') + } + if (args.a2 == null || args.a2.length != 169) { + throw Error('Group public key invalid!') + } +} + +/* Open Group Chat in Status */ +const handleGroupChat = (req, res) => { + try { + validateGroupChatArgs(req.query) + } catch(ex) { + handleError(`Invalid group chat URL: ${ex.message}`)(req, res) + return + } + let groupName = req.query.a1 + genPage(req, res, { + title: `Join "${groupName}" group chat in Status`, + info: `Join group chat ${groupName} in Status.`, + whitespace: true, /* Allow whitespace in group names */ + mainTarget: groupName, + headerName: groupName, + path: req.originalUrl, + }) +} + router.get('/.well-known/assetlinks.json', (req, res) => { res.json(assetLinks) }) @@ -163,6 +196,8 @@ router.get(/^\/chat\/public\/([a-z0-9-]+)$/, handlePublicChannel) /* Legacy */ router.get(/^\/chat\/public\/([a-zA-Z0-9-]+)$/, handleRedirect) router.get(/^\/([a-zA-Z0-9-]+)$/, (req, res) => res.redirect(req.originalUrl.toLowerCase())) +router.get('/g/:group(*)', handleGroupChat) + /* Catchall for everything else */ router.get('*', (req, res, next) => { if (req.query.redirect) { diff --git a/tests/main.js b/tests/main.js index be1c040..11ce430 100644 --- a/tests/main.js +++ b/tests/main.js @@ -1,4 +1,5 @@ import { test } from 'zora' +import crypto from 'crypto' import cheerio from 'cheerio' import request from 'supertest' import app from '../app' @@ -7,16 +8,8 @@ import assetLinks from '../resources/assetlinks.json' import appleSiteAssociation from '../resources/apple-app-site-association.json' const host = 'join.status.im' -const chatKey = 'e139115a1acc72510388fcf7e1cf492784c9a839888b25271465f4f1baa38c2d3997f8fd78828eb8628bc3bb55ababd884c6002d18330d59c404cc9ce3e4fb35' -const multibaseKey = 'fe70103e139115a1acc72510388fcf7e1cf492784c9a839888b25271465f4f1baa38c2d' -const compressedKey = 'zQ3shuoHL7WZEfKdexM6EyDRDhXBgcKz5SVw79stVMpmeyUvG' -const chatName = 'Lavender Trivial Goral' - const srv = request(app) - -const get = (path) => ( - srv.get(path).set('Host', host) -) +const get = (path) => srv.get(path).set('Host', host) /* helpers for querying returned HTML */ const q = (res, query) => cheerio.load(res.text)(query) @@ -74,6 +67,9 @@ test('test user ens routes', t => { }) test('test chat key routes', t => { + const chatName = 'Lavender Trivial Goral' + const chatKey = 'e139115a1acc72510388fcf7e1cf492784c9a839888b25271465f4f1baa38c2d3997f8fd78828eb8628bc3bb55ababd884c6002d18330d59c404cc9ce3e4fb35' + t.test(`/u/0x04${chatKey.substr(0,8)}... - VALID`, async t => { const res = await get(`/u/0x04${chatKey}`) t.eq(res.statusCode, 200, 'returns 200') @@ -106,6 +102,9 @@ test('test chat key routes', t => { }) test('test multibase chat key routes', t => { + const chatName = 'Lavender Trivial Goral' + const multibaseKey = 'fe70103e139115a1acc72510388fcf7e1cf492784c9a839888b25271465f4f1baa38c2d' + t.test(`/u/${multibaseKey.substr(0,12)}... - VALID`, async t => { const res = await get(`/u/${multibaseKey}`) t.eq(res.statusCode, 200, 'returns 200') @@ -123,6 +122,8 @@ test('test multibase chat key routes', t => { }) test('test compressed chat key routes', t => { + const compressedKey = 'zQ3shuoHL7WZEfKdexM6EyDRDhXBgcKz5SVw79stVMpmeyUvG' + t.test(`/u/${compressedKey.substr(0,12)}... - VALID`, async t => { const res = await get(`/u/${compressedKey}`) t.eq(res.statusCode, 200, 'returns 200') @@ -145,7 +146,7 @@ test('test public channel routes', t => { t.eq(res.statusCode, 200, 'returns 200') t.eq(meta(res, 'al:ios:url'), 'status-im://status-test', 'contains ios url') t.eq(meta(res, 'al:android:url'), 'status-im://status-test', 'contains android url') - t.eq(html(res, 'div#info'), 'Join public channel #status-test on Status.', 'contains prompt') + t.eq(html(res, 'div#info'), 'Join public channel #status-test in Status.', 'contains prompt') }) t.test('/staTus-TesT - UPPER CASE', async t => { /* we don't allow uppercase */ @@ -157,6 +158,42 @@ test('test public channel routes', t => { }) }) +test('group chat routes', t => { + const groupName = 'Secret%20Club' + const groupUUID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + const adminKey = '0x' + crypto.randomBytes(65).toString('hex') + const groupKey = groupUUID + '-0x' + crypto.randomBytes(65).toString('hex') + + t.test('/g/args?a1=Secret%20Club&... - VALID', async t => { + const res = await get(`/g/args?a=${adminKey}&a1=${groupName}&a2=${groupKey}`) + t.eq(res.statusCode, 200, 'returns 200') + t.eq(meta(res, 'al:ios:url'), `status-im://g/args?a=${adminKey}&a1=${groupName}&a2=${groupKey}`, 'contains ios url') + t.eq(meta(res, 'al:android:url'), `status-im://g/args?a=${adminKey}&a1=${groupName}&a2=${groupKey}`, 'contains android url') + t.eq(html(res, 'div#info'), 'Join group chat Secret Club in Status.', 'contains prompt') + }) + + t.test('/g/args?a1=Secret%20Club&.. - MISSING ARGS', async t => { + const res = await get(`/g/args?a=${adminKey}&a=${groupName}`) + t.eq(res.statusCode, 400, 'returns 400') + t.eq(html(res, 'h3#header'), 'Invalid input format', 'contains warning') + t.eq(html(res, 'code#error'), 'Invalid group chat URL: Missing arguments!', 'contains error') + }) + + t.test('/g/args?a1=Secret%20Club&.. - WRONG ADMIN KEY', async t => { + const res = await get(`/g/args?a=${adminKey.substr(0, 130)}&a1=${groupName}&a2=${groupKey}`) + t.eq(res.statusCode, 400, 'returns 400') + t.eq(html(res, 'h3#header'), 'Invalid input format', 'contains warning') + t.eq(html(res, 'code#error'), 'Invalid group chat URL: Admin public key invalid!', 'contains error') + }) + + t.test('/g/args?a1=Secret%20Club&.. - WRONG CHAT KEY', async t => { + const res = await get(`/g/args?a=${adminKey}&a1=${groupName}&a2=${groupKey.substr(0, 160)}`) + t.eq(res.statusCode, 400, 'returns 400') + t.eq(html(res, 'h3#header'), 'Invalid input format', 'contains warning') + t.eq(html(res, 'code#error'), 'Invalid group chat URL: Group public key invalid!', 'contains error') + }) +}) + test('test other routes', t => { t.test('/health', async t => { const res = await get('/health') diff --git a/utils/index.js b/utils/index.js index 1706b5c..31f9325 100644 --- a/utils/index.js +++ b/utils/index.js @@ -18,12 +18,13 @@ const makeQrCodeDataUri = (x) => ( QRCode.toDataURL(x, {width: 300}) ) -const isValidUrl = (text) => { +const isValidUrl = (text, useStd3ASCII=true) => { /* Remove protocol prefix */ noPrefix = text.replace(/(^\w+:|^)\/\//, ''); tokens = noPrefix.split('/') try { - uts46.toUnicode(tokens[0], {useStd3ASCII: true}) + /* useStd3ASCII=true accepts only DNS domain complaint urls */ + uts46.toUnicode(tokens[0], {useStd3ASCII}) } catch(ex) { return false }