Created
July 12, 2014 00:45
-
-
Save nateps/02d0b0293880905476bd to your computer and use it in GitHub Desktop.
Example ShareJS access control middleware for use with Derby
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Whitelist collections | |
ALLOW_COLLECTIONS = { | |
'accounts': true | |
'users': true | |
} | |
module.exports = (shareClient) -> | |
# Hold on to session object for later use. The HTTP req object is only | |
# available in the connect event | |
shareClient.use 'connect', (shareRequest, next) -> | |
shareRequest.agent.connectSession = shareRequest.req.session | |
next() | |
shareClient.use (shareRequest, next) -> | |
unless ALLOW_COLLECTIONS[shareRequest.collection] | |
return next '403: Cannot access collection' | |
next() | |
shareClient.use 'query', (shareRequest, next) -> | |
validateQueryRead( | |
shareRequest.agent, | |
shareRequest.collection, | |
shareRequest.query, | |
next | |
) | |
shareClient.filter (collection, docName, docData, next) -> | |
validateDocRead( | |
this, | |
collection, | |
docName, | |
docData.data, | |
next | |
) | |
shareClient.use 'submit', (shareRequest, next) -> | |
opData = shareRequest.opData | |
opData.connectSession = shareRequest.agent.connectSession | |
opData.collection = shareRequest.collection | |
opData.docName = shareRequest.docName | |
next() | |
shareClient.preValidate = (opData, docData) -> | |
# Validators is a list of functions to be called in the validate hook with | |
# the mutated document data. Note that ShareJS mutates the document without | |
# copying it first, so any values being compared against the original | |
# document should be cached by value in the closure scope. They should NOT | |
# be accessed from the document object in the validator | |
opData.validators = [] | |
# A ShareJS op is a list of mutations to be applied to a given document. | |
# Most of the time, this will be a single mutation. If so, we only have | |
# to check against that particular path | |
if !opData.op || opData.op.length is 1 | |
return preValidateWrite( | |
opData, | |
opData.collection, | |
opData.docName, | |
opData.op?[0].p || [], | |
docData.data | |
) | |
# Otherwise, we need to check for an error for each unique path being | |
# modified within the document | |
pathMap = {} | |
for component in opData.op | |
path = component.p || [] | |
key = path.join '.' | |
pathMap[key] = component.p | |
for key, path of pathMap | |
err = preValidateWrite( | |
opData, | |
opData.collection, | |
opData.docName, | |
path, | |
docData.data | |
) | |
return err if err | |
return | |
shareClient.validate = (opData, docData) -> | |
return unless opData.validators.length | |
doc = docData.data | |
for fn in opData.validators | |
err = fn doc, opData | |
return err if err | |
return | |
# Validating all possible Mongo queries is really difficult and not recommended. | |
# This is a simple validator that will at least restrict queries to those that | |
# contain an accountId, which provides a good first level of protection. The | |
# docs returned by the query are also validated, so while it is possible to leak | |
# data via well targeted queries, specifically restricting every query type is | |
# not recommended. Better than all of this is to not allow any queries created | |
# in the browser, and only create queries on the server. This could be done | |
# in a ShareJS middleware. | |
validateQueryRead = (agent, collection, query, next) -> | |
session = agent.connectSession | |
userId = session?.userId | |
accountId = session?.accountId | |
unless query | |
return next '403: No query specified' | |
unless session | |
console.error 'Warning: Query read access no session ', collection, query | |
return next '403: No session' | |
unless userId | |
console.error 'Warning: Query read access no session.userId ', collection, query, session | |
return next '403: No session.userId' | |
unless accountId | |
console.error 'Warning: Query read access no session.accountId ', collection, query, session | |
return next '403: No session.accountId' | |
query = query.$query if query.$query | |
# Protect queries by account. This gives us a simple base level of security | |
# from the most dangerous threat of outsiders gaining access. For more | |
# complex access control within accounts, we rely on access control of | |
# specific documents below. Note that this does not gaurd against queries | |
# to find out if specific documents exist or not within an account. A more | |
# ideal solution would not indicate when a document exists that the user | |
# does not have access to. | |
if collection is 'accounts' | |
return next() if query._id is accountId | |
return next '403: Cannot query accounts that are not yours.' | |
return next() if query.accountId is accountId | |
return next "403: Cannot query #{collection} from a different account." | |
validateDocRead = (agent, collection, docId, doc, next) -> | |
session = agent.connectSession | |
userId = session?.userId | |
accountId = session?.accountId | |
unless session | |
console.error 'Warning: Doc read access no session ', collection, docId | |
return next '403: No session' | |
unless userId | |
console.error 'Warning: Doc read access no session.userId ', collection, docId, session | |
return next '403: No session.userId' | |
unless accountId | |
console.error 'Warning: Doc read access no session.accountId ', collection, docId, session | |
return next '403: No session.accountId' | |
# Don't allow any user to access a document in a different account | |
unless docMatchesAccountId collection, docId, doc, accountId | |
return next "403: Cannot access document from another account #{collection}.#{docId}" | |
## APP SPECIFIC ACCESS RULES HERE ## | |
# Allow access to all documents within an account | |
return next() | |
# This function must be synchronous for important performance reasons. Any data | |
# needed to check access control rules must be fetched and stored on the session | |
# becuase the write is submitted | |
preValidateWrite = (opData, collection, docId, path, doc) -> | |
session = opData.connectSession | |
userId = session?.userId | |
accountId = session?.accountId | |
unless session | |
console.error 'Warning: Write access no session', arguments... | |
return '403: No session' | |
unless userId | |
console.error 'Warning: Write access no session.userId', arguments... | |
return '403: No session.userId' | |
unless accountId | |
console.error 'Warning: Write access no session.accountId', arguments... | |
return '403: No session.accountId' | |
doc ||= opData.create?.data | |
validators = opData.validators | |
# Don't allow any user to modify a document in a different account | |
unless doc | |
console.error 'Error: No document snapshot or create data', arguments... | |
return '403: No document snapshot or create data' | |
unless docMatchesAccountId collection, docId, doc, accountId | |
return "403: Account cannot modify document #{collection}.#{docId}" | |
# As a general pattern, if a user can typically edit a document type except | |
# under certain conditions, a validator function must be used to ensure that | |
# the condition is met after mutation. In such blacklisting cases, checking | |
# the path is NOT sufficient, as the entire document or a parent path might | |
# be edited instead. | |
# | |
# In contrast, if a user cannot typically edit a document type. It is OK | |
# to whitelist specific modifications by path. | |
if collection is 'accounts' | |
return '403: Cannot modify accounts' | |
else | |
# Ensure documents have a matching accountId after mutation | |
validators.push (mutatedDoc) -> | |
return if !mutatedDoc || mutatedDoc.accountId == accountId | |
return '403: Cannot modify a document to have a different accountId' | |
if collection is 'users' | |
# A user can modify whitelisted fields on their own document | |
if docId is userId | |
return if path[0] in ['name', 'email'] | |
return "403: Cannot modify #{path} of #{collection}.#{docId}" | |
# Users can't modify other users | |
return '403: Cannot modify user who is not you' | |
## APP SPECIFIC ACCESS RULES HERE ## | |
# Allow all other changes | |
return | |
docMatchesAccountId = (collection, docId, data, accountId) -> | |
return false unless collection && docId && data && accountId | |
return docId is accountId if collection is 'accounts' | |
return data.accountId is accountId | |
# Note that additional documents required to do read access control can be | |
# fetched from the ShareJS agent directly. It is much better to use ShareJS | |
# doc fetches instead of adding the overhead and potential for memory leaks | |
# from Racer models. Example: | |
checkSecret = (collection, docId, userId, cb) -> | |
unless docId | |
return cb '403: Cannot access document missing id reference' | |
agent.fetch collection, docId, (err, doc) -> | |
return cb err if err | |
return cb() unless doc.data?.secretTo == userId | |
cb '403: Cannot access secret document' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment