Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/api/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ Optional

{: .d-inline-block }

`doNotForwardSendPresenceErrorsToClient` -- boolean

Optional
{: .label .label-grey }

> Default: `false`

> If set to `true`, errors from the [`'sendPresence'`]({{ site.baseurl }}{% link middleware/actions.md %}#sendpresence) middleware will be passed to the `errorHandler` instead of being sent to the client. Sending these errors to the client is deprecated

{: .d-inline-block }

`doNotCommitNoOps` -- boolean

Optional
Expand Down
2 changes: 2 additions & 0 deletions docs/api/presence.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ Optional

{: .warn }
> Depending on use-case, the same client may have **multiple** presences, so a user or client ID may not be appropriate to use as a presence ID.
>
> Presence IDs are sent to every subscriber, so the server should check that a client only uses its own. See [Access control]({{ site.baseurl }}{% link presence.md %}#presence-ids).

Return value

Expand Down
6 changes: 6 additions & 0 deletions docs/middleware/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ This action has these additional `context` properties:

Presence information has just been received from a client. The presence has not yet been transformed against any ops it has missed.

{: .warn }
Presence is not covered by document permissions. See [Access control]({{ site.baseurl }}{% link presence.md %}#access-control).

This action has these additional `context` properties:

`collection` -- string
Expand All @@ -83,6 +86,9 @@ This action has these additional `context` properties:

Presence information is about to be sent to a client.

{: .warn }
Presence is not covered by document permissions. See [Access control]({{ site.baseurl }}{% link presence.md %}#access-control).

This action has these additional `context` properties:

`collection` -- string
Expand Down
73 changes: 73 additions & 0 deletions docs/presence.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,76 @@ const localPresence = presence.create()
// The presence value depends on the type
localPresence.submit(value)
```

## Access control

{: .warn }
Presence is **not** covered by document permissions: being able to read or write a document does not control who can see or send presence on it. Use [middleware]({{ site.baseurl }}{% link middleware/index.md %}) to restrict presence.

### Receiving presence

Subscribing to presence doesn't trigger any presence middleware, so any client can subscribe to any presence channel -- including the presence of a document it can't read.

To stop a client receiving presence, reject the update in the [`'sendPresence'`]({{ site.baseurl }}{% link middleware/actions.md %}#sendpresence) middleware. For typed presence, ShareDB makes sure that `presence.c` and `presence.d` match the presence channel. This middleware runs for every update about to be sent to every client, so keep the check cheap -- for example, by caching permissions on [`agent.custom`]({{ site.baseurl }}{% link api/agent.md %}#custom--object).

{: .warn }
Rejecting updates in `'sendPresence'` only stops them reaching the client if you set the [`doNotForwardSendPresenceErrorsToClient`]({{ site.baseurl }}{% link api/backend.md %}#options) option. Otherwise, the client is sent an error for each rejected update, including its presence ID. With the option set, each rejected update is passed to the `errorHandler` instead.

### Sending presence

ShareDB rejects typed presence whose collection and document ID don't match its channel. It also only broadcasts typed presence if the client can read the document, as determined by the [`'readSnapshots'`]({{ site.baseurl }}{% link middleware/actions.md %}#readsnapshots) middleware. This is checked when the client starts sending presence on the document, not on every update.

ShareDB doesn't check:

- whether the client can write to the document
- `null` presence, which is sent when a client leaves
- untyped presence

Check these in the [`'receivePresence'`]({{ site.baseurl }}{% link middleware/actions.md %}#receivepresence) middleware if you need to.

If you only use typed presence, reject untyped presence in `'receivePresence'`: older clients still apply untyped presence sent on a document's channel.

### Presence IDs

Presence IDs are chosen by the client, and sent to every subscriber, so a client could use another client's presence ID to overwrite or clear its presence.

To prevent this, tie presence IDs to the user in `'receivePresence'` -- for example by requiring them to start with the user ID:

```js
const localPresence = presence.create(`${userId}:${randomId}`)
```

{: .info }
Keep a random suffix, so that the same user can have more than one presence -- for example, in multiple browser tabs.

### Example

```js
backend.use('receivePresence', (context, next) => {
// agent.custom is usually set in the 'connect' hook
const userId = context.agent.custom.userId
const presence = context.presence
if (!presence.id.startsWith(`${userId}:`)) {
return next(new Error('Unauthorized'))
}
// This app only uses typed presence
if (!presence.c) {
return next(new Error('Unauthorized'))
}
// Let users clear their own presence, even if they've lost access
if (presence.p === null) return next()
if (!userCanChangeDoc(userId, presence.c, presence.d)) {
return next(new Error('Unauthorized'))
}
next()
})

backend.use('sendPresence', (context, next) => {
const userId = context.agent.custom.userId
const presence = context.presence
if (!userCanReadDoc(userId, presence.c, presence.d)) {
return next(new Error('Unauthorized'))
}
next()
})
```
12 changes: 12 additions & 0 deletions lib/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ var ACTIONS = require('./message-actions').ACTIONS;
var types = require('./types');
var util = require('./util');
var protocol = require('./protocol');
var DocPresence = require('./client/presence/doc-presence');

var ERROR_CODE = ShareDBError.CODES;

Expand Down Expand Up @@ -423,6 +424,9 @@ Agent.prototype._checkRequest = function(request) {
if (typeof request.id !== 'string' || util.isDangerousProperty(request.id)) {
return 'Invalid presence ID';
}
if (isDocPresence(request) && !matchesDocPresenceChannel(request)) {
return 'Invalid presence channel';
}
}
} else if (
request.a === ACTIONS.bulkFetch ||
Expand All @@ -444,6 +448,14 @@ Agent.prototype._checkRequest = function(request) {
}
};

function isDocPresence(request) {
return request.c != null || request.d != null;
}

function matchesDocPresenceChannel(request) {
return !!request.c && !!request.d && request.ch === DocPresence.channel(request.c, request.d);
}

// Handle an incoming message from the client
Agent.prototype._handleMessage = function(request, callback) {
try {
Expand Down
6 changes: 6 additions & 0 deletions lib/client/presence/doc-presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ DocPresence.channel = function(collection, id) {
return collection + '.' + id;
};

DocPresence.prototype._receiveUpdate = function(error, message) {
var isForThisDoc = message.c === this.collection && message.d === this.id;
if (!error && !isForThisDoc) return;
Comment thread
alecgibson marked this conversation as resolved.
Presence.prototype._receiveUpdate.call(this, error, message);
};

DocPresence.prototype._createLocalPresence = function(id) {
return new LocalDocPresence(this, id);
};
Expand Down
3 changes: 2 additions & 1 deletion lib/client/presence/presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ Presence.prototype._handleUnsubscribe = function(error, seq) {
};

Presence.prototype._receiveUpdate = function(error, message) {
var isForwardedError = !!error && message.pv == null;
var localPresence = util.dig(this.localPresences, message.id);
if (localPresence) return localPresence._ack(error, message.pv);
if (localPresence && !isForwardedError) return localPresence._ack(error, message.pv);

if (error) return this.emit('error', error);
var presence = this;
Expand Down
Loading
Loading