33'use strict'
44
55import sbp from '@sbp/sbp'
6- import { GIMessage } from '~/shared/domains/chelonia/GIMessage .js'
7- import { createCID } from '~/shared/functions.js'
6+ import { SPMessage } from '~/shared/domains/chelonia/SPMessage .js'
7+ import { createCID , multicodes , maybeParseCID } from '~/shared/functions.js'
88import { SERVER_INSTANCE } from './instance-keys.js'
99import path from 'path'
1010import chalk from 'chalk'
@@ -42,6 +42,14 @@ const limiterPerDay = new Bottleneck.Group({
4242 reservoirRefreshAmount : SIGNUP_LIMIT_DAY
4343} )
4444
45+ const cidLookupTable = {
46+ [ multicodes . SHELTER_CONTRACT_MANIFEST ] : 'application/vnd.shelter.contractmanifest+json' ,
47+ [ multicodes . SHELTER_CONTRACT_TEXT ] : 'application/vnd.shelter.contracttext' ,
48+ [ multicodes . SHELTER_CONTRACT_DATA ] : 'application/vnd.shelter.contractdata+json' ,
49+ [ multicodes . SHELTER_FILE_MANIFEST ] : 'application/vnd.shelter.filemanifest+json' ,
50+ [ multicodes . SHELTER_FILE_CHUNK ] : 'application/vnd.shelter.filechunk+octet-stream'
51+ }
52+
4553// Constant-time equal
4654const ctEq = ( expected : string , actual : string ) => {
4755 let r = actual . length ^ expected . length
@@ -61,6 +69,10 @@ const staticServeConfig = {
6169 redirect : isCheloniaDashboard ? '/dashboard/' : '/app/'
6270}
6371
72+ // We define a `Proxy` for route so that we can use `route.VERB` syntax for
73+ // defining routes instead of calling `server.route` with an object, and to
74+ // dynamically get the HAPI server object from the `SERVER_INSTANCE`, which is
75+ // defined in `server.js`.
6476const route = new Proxy ( { } , {
6577 get : function ( obj , prop ) {
6678 return function ( path : string , options : Object , handler : Function | Object ) {
@@ -79,9 +91,6 @@ function notFoundNoCache (h) {
7991
8092// RESTful API routes
8193
82- // TODO: Update this regex once `chel` uses prefixed manifests
83- const manifestRegex = / ^ z 9 b r R u 3 V [ 1 2 3 4 5 6 7 8 9 A B C D E F G H J K L M N P Q R S T U V W X Y Z a b c d e f g h i j k m n o p q r s t u v w x y z ] { 44 } $ /
84-
8594// NOTE: We could get rid of this RESTful API and just rely on pubsub.js to do this
8695// —BUT HTTP2 might be better than websockets and so we keep this around.
8796// See related TODO in pubsub.js and the reddit discussion link.
@@ -96,9 +105,10 @@ route.POST('/event', {
96105 // X-Real-IP HEADER! OTHERWISE THIS IS EASILY SPOOFED!
97106 const ip = request . headers [ 'x-real-ip' ] || request . info . remoteAddress
98107 try {
99- const deserializedHEAD = GIMessage . deserializeHEAD ( request . payload )
108+ const deserializedHEAD = SPMessage . deserializeHEAD ( request . payload )
100109 try {
101- if ( ! manifestRegex . test ( deserializedHEAD . head . manifest ) ) {
110+ const parsed = maybeParseCID ( deserializedHEAD . head . manifest )
111+ if ( parsed ?. code !== multicodes . SHELTER_CONTRACT_MANIFEST ) {
102112 return Boom . badData ( 'Invalid manifest' )
103113 }
104114 const credentials = request . auth . credentials
@@ -195,8 +205,17 @@ route.GET('/eventsAfter/{contractID}/{since}/{limit?}', {}, async function (requ
195205 const { contractID, since, limit } = request . params
196206 const ip = request . headers [ 'x-real-ip' ] || request . info . remoteAddress
197207 try {
198- if ( contractID . startsWith ( '_private' ) || since . startsWith ( '_private' ) ) {
199- return Boom . notFound ( )
208+ if (
209+ ! contractID ||
210+ contractID . startsWith ( '_private' ) ||
211+ ! / ^ [ 0 - 9 ] + $ / . test ( since ) ||
212+ ( limit && ! / ^ [ 0 - 9 ] + $ / . test ( limit ) )
213+ ) {
214+ return Boom . badRequest ( )
215+ }
216+ const parsed = maybeParseCID ( contractID )
217+ if ( parsed ?. code !== multicodes . SHELTER_CONTRACT_DATA ) {
218+ return Boom . badRequest ( )
200219 }
201220
202221 const stream = await sbp ( 'backend/db/streamEntriesAfter' , contractID , since , limit )
@@ -267,10 +286,10 @@ route.POST('/name', {
267286route . GET ( '/name/{name}' , { } , async function ( request , h ) {
268287 const { name } = request . params
269288 try {
270- // TODO: conflict with PR 2494
271- const r = await sbp ( 'backend/db/lookupName' , name )
272- if ( typeof r !== 'string' ) return r
273- return h . response ( r ) . header ( 'content-type' , 'text/plain' )
289+ const lookupResult = await sbp ( 'backend/db/lookupName' , name )
290+ return lookupResult
291+ ? h . response ( lookupResult ) . type ( 'text/plain' )
292+ : notFoundNoCache ( h )
274293 } catch ( err ) {
275294 logger . error ( err , `GET /name/${ name } ` , err . message )
276295 return err
@@ -282,7 +301,13 @@ route.GET('/latestHEADinfo/{contractID}', {
282301} , async function ( request , h ) {
283302 const { contractID } = request . params
284303 try {
285- if ( contractID . startsWith ( '_private' ) ) return Boom . notFound ( )
304+ if (
305+ ! contractID ||
306+ contractID . startsWith ( '_private' )
307+ ) return Boom . badRequest ( )
308+ const parsed = maybeParseCID ( contractID )
309+ if ( parsed ?. code !== multicodes . SHELTER_CONTRACT_DATA ) return Boom . badRequest ( )
310+
286311 const HEADinfo = await sbp ( 'chelonia/db/latestHEADinfo' , contractID )
287312 if ( ! HEADinfo ) {
288313 console . warn ( `[backend] latestHEADinfo not found for ${ contractID } ` )
@@ -347,7 +372,11 @@ if (process.env.NODE_ENV === 'development') {
347372 const { hash, data } = request . payload
348373 if ( ! hash ) return Boom . badRequest ( 'missing hash' )
349374 if ( ! data ) return Boom . badRequest ( 'missing data' )
350- const ourHash = createCID ( data )
375+
376+ const parsed = maybeParseCID ( hash )
377+ if ( ! parsed ) return Boom . badRequest ( 'invalid hash' )
378+
379+ const ourHash = createCID ( data , parsed . code )
351380 if ( ourHash !== hash ) {
352381 console . error ( `hash(${ hash } ) != ourHash(${ ourHash } )` )
353382 return Boom . badRequest ( 'bad hash!' )
@@ -419,7 +448,7 @@ route.POST('/file', {
419448 if ( ! request . payload [ i ] || ! ( request . payload [ i ] . payload instanceof Uint8Array ) ) {
420449 throw Boom . badRequest ( 'chunk missing in submitted data' )
421450 }
422- const ourHash = createCID ( request . payload [ i ] . payload )
451+ const ourHash = createCID ( request . payload [ i ] . payload , multicodes . SHELTER_FILE_CHUNK )
423452 if ( request . payload [ i ] . payload . byteLength !== chunk [ 0 ] ) {
424453 throw Boom . badRequest ( 'bad chunk size' )
425454 }
@@ -433,7 +462,7 @@ route.POST('/file', {
433462 // Finally, verify the size is correct
434463 if ( ourSize !== manifest . size ) return Boom . badRequest ( 'Mismatched total size' )
435464
436- const manifestHash = createCID ( manifestMeta . payload )
465+ const manifestHash = createCID ( manifestMeta . payload , multicodes . SHELTER_FILE_MANIFEST )
437466
438467 // Check that we're not overwriting data. At best this is a useless operation
439468 // since there is no need to write things that exist. However, overwriting
@@ -473,18 +502,33 @@ route.POST('/file', {
473502route . GET ( '/file/{hash}' , { } , async function ( request , h ) {
474503 const { hash } = request . params
475504
476- if ( hash . startsWith ( '_private' ) ) {
477- return Boom . notFound ( )
505+ if ( ! hash || hash . startsWith ( '_private' ) ) {
506+ return Boom . badRequest ( )
507+ }
508+ const parsed = maybeParseCID ( hash )
509+ if ( ! parsed ) {
510+ return Boom . badRequest ( )
478511 }
479512
480513 const blobOrString = await sbp ( 'chelonia.db/get' , `any:${ hash } ` )
481514 if ( ! blobOrString ) {
482515 return notFoundNoCache ( h )
483516 }
484- // TODO: conflict with PR 2494
485- return h . response ( blobOrString ) . etag ( hash )
517+
518+ const type = cidLookupTable [ parsed . code ] || 'application/octet-stream'
519+
520+ return h
521+ . response ( blobOrString )
522+ . etag ( hash )
486523 . header ( 'Cache-Control' , 'public,max-age=31536000,immutable' )
487- . header ( 'content-type' , 'application/octet-stream' )
524+ // CSP to disable everything -- this only affects direct navigation to the
525+ // `/file` URL.
526+ // The CSP below prevents any sort of resource loading or script execution
527+ // on direct navigation. The `nosniff` header instructs the browser to
528+ // honour the provided content-type.
529+ . header ( 'content-security-policy' , "default-src 'none'; frame-ancestors 'none'; form-action 'none'; upgrade-insecure-requests; sandbox" )
530+ . header ( 'x-content-type-options' , 'nosniff' )
531+ . type ( type )
488532} )
489533
490534route . POST ( '/deleteFile/{hash}' , {
@@ -497,7 +541,14 @@ route.POST('/deleteFile/{hash}', {
497541} , async function ( request , h ) {
498542 const { hash } = request . params
499543 const strategy = request . auth . strategy
500- if ( ! hash || hash . startsWith ( '_private' ) ) return Boom . notFound ( )
544+ if ( ! hash || hash . startsWith ( '_private' ) ) {
545+ return Boom . badRequest ( )
546+ }
547+ const parsed = maybeParseCID ( hash )
548+ if ( parsed ?. code !== multicodes . SHELTER_FILE_MANIFEST ) {
549+ return Boom . badRequest ( )
550+ }
551+
501552 const owner = await sbp ( 'chelonia.db/get' , `_private_owner_${ hash } ` )
502553 if ( ! owner ) {
503554 return Boom . notFound ( )
@@ -593,8 +644,13 @@ route.POST('/kv/{contractID}/{key}', {
593644} , async function ( request , h ) {
594645 const { contractID, key } = request . params
595646
596- if ( key . startsWith ( '_private' ) ) {
597- return Boom . notFound ( )
647+ // The key is mandatory and we don't allow NUL in it as it's used for indexing
648+ if ( ! key || key . includes ( '\x00' ) || key . startsWith ( '_private' ) ) {
649+ return Boom . badRequest ( )
650+ }
651+ const parsed = maybeParseCID ( contractID )
652+ if ( parsed ?. code !== multicodes . SHELTER_CONTRACT_DATA ) {
653+ return Boom . badRequest ( )
598654 }
599655
600656 if ( ! ctEq ( request . auth . credentials . billableContractID , contractID ) ) {
@@ -628,7 +684,7 @@ route.POST('/kv/{contractID}/{key}', {
628684 // pass through
629685 } else {
630686 // "Quote" string (to match ETag format)
631- const cid = JSON . stringify ( createCID ( existing ) )
687+ const cid = JSON . stringify ( createCID ( existing , multicodes . RAW ) )
632688 if ( ! expectedEtag . split ( ',' ) . map ( v => v . trim ( ) ) . includes ( cid ) ) {
633689 return Boom . preconditionFailed ( )
634690 }
@@ -674,8 +730,12 @@ route.GET('/kv/{contractID}/{key}', {
674730} , async function ( request , h ) {
675731 const { contractID, key } = request . params
676732
677- if ( key . startsWith ( '_private' ) ) {
678- return Boom . notFound ( )
733+ if ( ! key || key . includes ( '\x00' ) || key . startsWith ( '_private' ) ) {
734+ return Boom . badRequest ( )
735+ }
736+ const parsed = maybeParseCID ( contractID )
737+ if ( parsed ?. code !== multicodes . SHELTER_CONTRACT_DATA ) {
738+ return Boom . badRequest ( )
679739 }
680740
681741 if ( ! ctEq ( request . auth . credentials . billableContractID , contractID ) ) {
@@ -687,8 +747,12 @@ route.GET('/kv/{contractID}/{key}', {
687747 return notFoundNoCache ( h )
688748 }
689749
750+ < << << << HEAD
690751 // TODO: conflict with PR 2494
691752 return h . response ( result ) . etag ( createCID ( result ) ) . header ( 'content-type' , 'application/json' )
753+ === = ===
754+ return h . response ( result ) . etag ( createCID ( result , multicodes . RAW ) )
755+ >>> > >>> master
692756} )
693757
694758// SPA routes
@@ -775,7 +839,7 @@ route.POST('/zkpp/register/{name}', {
775839 if ( ! credentials ?. billableContractID ) {
776840 return Boom . unauthorized ( 'Registering a salt requires ownership information' , 'shelter' )
777841 }
778- if ( req . params [ 'name' ] . startsWith ( '_private' ) ) return Boom . notFound ( )
842+ if ( req . params [ 'name' ] . startsWith ( '_private' ) ) return Boom . badRequest ( )
779843 const contractID = await sbp ( 'backend/db/lookupName' , req . params [ 'name' ] )
780844 if ( contractID !== credentials . billableContractID ) {
781845 // This ensures that only the owner of the contract can set a salt for it,
@@ -814,7 +878,7 @@ route.GET('/zkpp/{name}/auth_hash', {
814878 query : Joi . object ( { b : Joi . string ( ) . required ( ) } )
815879 }
816880} , async function ( req , h ) {
817- if ( req . params [ 'name' ] . startsWith ( '_private' ) ) return Boom . notFound ( )
881+ if ( req . params [ 'name' ] . startsWith ( '_private' ) ) return Boom . badRequest ( )
818882 try {
819883 const challenge = await getChallenge ( req . params [ 'name' ] , req . query [ 'b' ] )
820884
@@ -843,7 +907,7 @@ route.GET('/zkpp/{name}/contract_hash', {
843907 } )
844908 }
845909} , async function ( req , h ) {
846- if ( req . params [ 'name' ] . startsWith ( '_private' ) ) return Boom . notFound ( )
910+ if ( req . params [ 'name' ] . startsWith ( '_private' ) ) return Boom . badRequest ( )
847911 try {
848912 const salt = await getContractSalt ( req . params [ 'name' ] , req . query [ 'r' ] , req . query [ 's' ] , req . query [ 'sig' ] , req . query [ 'hc' ] )
849913
@@ -871,7 +935,7 @@ route.POST('/zkpp/{name}/updatePasswordHash', {
871935 } )
872936 }
873937} , async function ( req , h ) {
874- if ( req . params [ 'name' ] . startsWith ( '_private' ) ) return Boom . notFound ( )
938+ if ( req . params [ 'name' ] . startsWith ( '_private' ) ) return Boom . badRequest ( )
875939 try {
876940 const result = await updateContractSalt ( req . params [ 'name' ] , req . payload [ 'r' ] , req . payload [ 's' ] , req . payload [ 'sig' ] , req . payload [ 'hc' ] , req . payload [ 'Ea' ] )
877941
0 commit comments