Repository navigation
🔒️ Only accept presence for the doc on its channel #752
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -76,3 +76,86 @@ 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. | ||
|
|
||
| ### Who can see 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, so you can check read access with them. 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, which logs it by default. | ||
|
|
||
| ### Who can send 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. | ||
|
|
||
| Older clients also apply presence for another document whose collection and ID join to the same channel, which can happen when collection names contain `.` -- for example, `docs.a`/`b` and `docs`/`a.b` both use the `docs.a.b` channel. The server can't tell these apart, so avoid `.` in the names of collections that use presence until all clients are upgraded. | ||
|
|
||
| ### 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, or to stop its later updates being applied. Older clients also fire their pending submit callbacks early when they receive presence with their own presence ID. | ||
|
|
||
| To prevent this, tie presence IDs to the user in `'receivePresence'` -- for example by requiring them to start with the user ID and a separator that can't appear in user IDs: | ||
|
|
||
| ```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 | ||
| const backend = new Backend({ | ||
| presence: true, | ||
| doNotForwardSendPresenceErrorsToClient: true, | ||
| errorHandler: (error) => { | ||
| if (error.code !== 'PRESENCE_FORBIDDEN') console.error(error) | ||
| } | ||
| }) | ||
|
|
||
| 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 (!userId || !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({code: 'PRESENCE_FORBIDDEN', message: 'Forbidden'}) | ||
| } | ||
| next() | ||
| }) | ||
|
Comment on lines
+123
to
+160
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🤖 Security · should] F1: The example rejects updates in PoC with this exact middleware on a default This comes from agent.js:991. That tells Mallory who is on the doc and when, which is #738's eavesdropping case. The warning at L92 explains this, but it's a few paragraphs above the code people will paste. Suggestion: Put the option in the example itself: const backend = new Backend({presence: true, doNotForwardSendPresenceErrorsToClient: true})or add a comment on the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: the example now sets |
||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
alecgibson marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🤖 Readability · nit] F9: Letting every error past the "is this for my doc" filter is deliberate, because server error frames have no Suggestion: // Error frames from the server have no c/d, so Presence._receiveUpdate routes them
if (!error && !isForThisDoc) return;
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as F3: no comment, by house style. The error exemption is pinned by "emits a forwarded error for a presence with the same ID on the presence, not its local presence", which fails if errors get filtered here. |
||
| Presence.prototype._receiveUpdate.call(this, error, message); | ||
| }; | ||
|
|
||
| DocPresence.prototype._createLocalPresence = function(id) { | ||
| return new LocalDocPresence(this, id); | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[🤖 Security · nit] F6: Untyped presence isn't the only thing older clients still apply from another doc. Dotted collection names collide too:
docs.a/banddocs/a.bboth map to channeldocs.a.b, somatchesDocPresenceChannelaccepts both.PoC: Mallory can read
docs.a/band sends{ch:'docs.a.b', c:'docs.a', d:'b', ...}. The server accepts it and it reaches Alice'sdocs/a.bDocPresence. Upgraded clients drop it thanks to the exactc/dcheck, but a pre-PR client applies it. This needs collections with dots, so it's rare.Suggestion: Extend this line: older clients also apply presence from another doc that shares the channel name, which can happen when collection names contain
..There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: the docs now cover dotted-name collisions, and say to avoid
.in the names of collections that use presence until all clients are upgraded. Upgraded clients'DocPresencealready ignores it, and there's a test for that.