add support for private groups at /g/

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 <jakub@status.im>
This commit is contained in:
Jakub Sokołowski 2020-09-29 13:19:25 +02:00 committed by Jakub
parent 8d74ea7969
commit 5fd8dd2466
3 changed files with 88 additions and 15 deletions

View File

@ -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 <span class="inline-block align-bottom w-32 truncate">#${chatName}</span> on Status.`,
info: `Join public channel <span class="inline-block align-bottom w-32 truncate">#${chatName}</span> 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 <span class="inline-block align-bottom w-32 truncate">${groupName}</span> 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) {

View File

@ -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 <span class=\"inline-block align-bottom w-32 truncate\">#status-test</span> on Status.', 'contains prompt')
t.eq(html(res, 'div#info'), 'Join public channel <span class=\"inline-block align-bottom w-32 truncate\">#status-test</span> 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 <span class=\"inline-block align-bottom w-32 truncate\">Secret Club</span> 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')

View File

@ -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
}