-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathfirestore.rules
More file actions
277 lines (256 loc) · 14 KB
/
Copy pathfirestore.rules
File metadata and controls
277 lines (256 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// The caller's own /users doc, as a DocumentReference. Signals and comments
// store their owner as a reference to this doc, so every ownership check
// below compares against it.
function userDoc() {
return /databases/$(database)/documents/users/$(request.auth.uid);
}
// The signal's reporter (owner), compared by DocumentReference path.
// Used by both the prod and test signal update/delete rules so the
// security-critical ownership check lives in exactly one place.
function isSignalReporter() {
return resource.data.reporter == userDoc();
}
// The caller is the reporter of the parent signal at {coll}/{signalId}.
// Backs the comment-delete cascade for both the prod and test collections,
// so that ownership check lives in one place instead of being inlined twice.
function isParentSignalReporter(coll, signalId) {
return get(/databases/$(database)/documents/$(coll)/$(signalId)).data.reporter
== userDoc();
}
// Validates a signal create (M-1): reporter pinned to the caller (no
// impersonation) plus basic type/size bounds on the content fields, to curb
// content abuse and read/storage-cost inflation.
//
// NOTE: this deliberately does NOT block anonymous callers yet — see the
// comment on the signals `create` rule below (HelpAPaw/Flutter#67).
function isSignalCreate() {
return request.auth != null
&& request.resource.data.reporter == userDoc()
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() <= 300
&& request.resource.data.description is string
&& request.resource.data.description.size() <= 10000
&& request.resource.data.signalType is int
&& request.resource.data.signalType >= 0
&& request.resource.data.signalType <= 6;
}
// Validates a comment create (M-1): author pinned to the caller. Covers both
// comment shapes — user text comments and the `status_change` system
// comments (which carry no `text`), so the text bounds only apply when a
// `text` field is present.
function isCommentCreate() {
return request.auth != null
&& request.resource.data.author == userDoc()
&& (
!('text' in request.resource.data)
|| (request.resource.data.text is string
&& request.resource.data.text.size() > 0
&& request.resource.data.text.size() <= 2000)
);
}
// Validates the display name on a publicProfiles write (L-2). Bounds the
// length and rejects control characters — a newline or a NUL in a name that
// is rendered next to every signal and comment is only ever abuse.
//
// `matches()` is a whole-string RE2 match. The pattern reads as "any run of
// non-control characters containing at least one that isn't a space", so it
// also rejects "" and whitespace-only names, which would render as a blank
// author and read as a deleted/unknown user.
function isValidProfileName() {
return request.resource.data.name is string
&& request.resource.data.name.size() <= 100
&& request.resource.data.name
.matches('[^\\x00-\\x1f]*[^\\x00-\\x20][^\\x00-\\x1f]*');
}
// Non-reporters may ONLY advance a signal's status (the volunteer flow),
// and must self-stamp lastUpdatedBy so it can't be spoofed to another user.
function isStatusOnlyUpdate() {
return request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['status', 'lastUpdatedBy'])
&& request.resource.data.lastUpdatedBy == userDoc()
&& request.resource.data.status is int
&& request.resource.data.status >= 0
&& request.resource.data.status <= 2;
}
// Users collection - private profile (tokens, location, prefs, phone).
// Owner-only: never expose to other users.
//
// NOTE: rules do NOT cascade into subcollections. This block covers the user
// document only — `users/{uid}/notifications/{id}` needs its own match below,
// and so would any future subcollection.
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// In-app notification inbox, one document per notification per recipient.
// Written server-side by the fan-out (Admin SDK, bypasses rules) and
// client-side by the arrival catch-up (NearbySignalChecker), which runs in a
// headless isolate.
match /users/{userId}/notifications/{notificationId} {
allow read: if request.auth != null && request.auth.uid == userId;
// The only client-side writer is the catch-up, and it only ever produces
// `nearby_signal`. Pinning the type stops a client fabricating a
// `status_change` entry it was never sent. The size caps are the real
// security value: without them an owner-only collection is a free-storage
// vector. `expiresAt` is required or the document would outlive the TTL
// policy forever.
allow create: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.keys().hasOnly([
'type', 'title', 'body', 'read', 'signalId',
'signalTitle', 'signalType', 'testMode',
'createdAt', 'expiresAt'
])
&& request.resource.data.type == 'nearby_signal'
&& request.resource.data.read == false
&& request.resource.data.signalId is string
&& request.resource.data.signalId.size() <= 200
&& request.resource.data.title is string
&& request.resource.data.title.size() <= 300
&& request.resource.data.body is string
&& request.resource.data.body.size() <= 1000
&& request.resource.data.signalTitle is string
&& request.resource.data.signalTitle.size() <= 300
&& request.resource.data.signalType is int
&& request.resource.data.testMode is bool
&& request.resource.data.createdAt is timestamp
&& request.resource.data.expiresAt is timestamp;
// Diff-based: the page only ever flips `read`, so a client can't rewrite
// the title/body/signalId of a notification after the fact.
allow update: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['read'])
&& request.resource.data.read is bool;
allow delete: if request.auth != null && request.auth.uid == userId;
}
// User live location - kept separate from the user doc so high-frequency
// location writes don't trigger the token-dedupe Cloud Function. Owner-only;
// the notification fan-out reads it via the Admin SDK (bypasses rules).
match /userLocations/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Unread-notification counter, source of the iOS app badge. Top-level for
// the same reason as userLocations: `onUserTokensWritten` fires on every
// `users/{uid}` write, so a counter on the user doc would cost one function
// invocation per recipient per notification.
//
// The value is advisory — it drifts on Cloud Function retries and TTL
// deletions — and the client repairs it with a count() aggregation on
// resume. That is why the owner may write it directly.
match /userCounters/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow write: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.keys().hasOnly(['unread', 'updatedAt'])
&& request.resource.data.unread is int
&& request.resource.data.unread >= 0;
}
// Public profiles - just the display name, readable by any signed-in user
// (incl. anonymous) so reporter/comment-author names resolve for everyone.
// Writable only by the owner. On account deletion this is overwritten with
// "Deleted user" so erasure propagates to all signals/comments dynamically.
match /publicProfiles/{userId} {
// Single-document reads only (L-2). The app resolves names one uid at a
// time (PublicProfileService.getName), so denying `list` costs it nothing
// and stops the entire user base being enumerated from one query. Do NOT
// widen this back to `read` — that grants `list` again.
allow get: if request.auth != null;
allow list: if false;
// The only field the app ever writes here is `name`. `deleted`/`deletedAt`
// are tombstone fields written by deleteAccount through the Admin SDK,
// which bypasses rules — so restricting the caller to `name` costs
// nothing and stops a user clearing their own "Deleted user" tombstone.
allow create: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.keys().hasOnly(['name'])
&& isValidProfileName();
// Diff-based (not `keys()`) so a name edit on a doc that already carries
// the tombstone fields isn't rejected for merely containing them.
allow update: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['name'])
&& isValidProfileName();
allow delete: if request.auth != null && request.auth.uid == userId;
}
// Signals collection - public read, authenticated write
match /signals/{signalId} {
allow read: if true; // Public read - signals are public data
// M-1, partial: binds `reporter` to the caller and bounds the content
// fields. The remaining half of M-1 — blocking anonymous callers
// server-side — is NOT here yet: it has to gate on
// `request.auth.token.email_verified`, which is baked into the ID token at
// mint time, so a user who verifies (or upgrades an anonymous account in
// place) mid-session keeps a stale `false` claim and gets denied. The
// client fix that force-refreshes the token (633da3b) is on dev but not in
// any released build, so that clause stays out until it ships.
// Tracking: HelpAPaw/Flutter#67.
allow create: if isSignalCreate();
// Reporter may edit any field; anyone else signed in may only advance the
// status (self-stamping lastUpdatedBy). Prevents non-reporters from
// rewriting reporter/title/phone/photos or taking over a signal.
allow update: if request.auth != null
&& (isSignalReporter() || isStatusOnlyUpdate());
// Only the signal's reporter may delete it
allow delete: if request.auth != null && isSignalReporter();
// Comments subcollection
match /comments/{commentId} {
allow read: if true; // Public read
allow create: if isCommentCreate();
// The signal's reporter may delete comments (enables delete-signal cascade)
allow delete: if request.auth != null
&& isParentSignalReporter('signals', signalId);
}
}
// Test signals collection - same rules as signals
match /signals_test/{signalId} {
allow read: if true;
allow create: if isSignalCreate();
// Same rules as prod signals (see helpers above).
allow update: if request.auth != null
&& (isSignalReporter() || isStatusOnlyUpdate());
allow delete: if request.auth != null && isSignalReporter();
match /comments/{commentId} {
allow read: if true;
allow create: if isCommentCreate();
allow delete: if request.auth != null
&& isParentSignalReporter('signals_test', signalId);
}
}
// Allow collection group queries for comments
match /{path=**}/comments/{commentId} {
allow read: if request.auth != null;
}
// Feedback collection - only admins can read (via Admin SDK/Console).
// Any signed-in caller (incl. the automatic anonymous app sessions) may
// submit, but (M-2):
// - auth is required — no unauthenticated writes; combined with App Check
// enforcement this closes the open email/cost-abuse vector.
// - userId is pinned to the caller so it can't be spoofed to frame another
// user (the email/rate-limit both key off it).
// - email, when present, must be a syntactically valid address (also
// re-validated in the function before it's used as replyTo).
// Each accepted write triggers onFeedbackCreated, which sends an email and
// enforces a per-user rate limit.
match /feedback/{feedbackId} {
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid
&& request.resource.data.message is string
&& request.resource.data.message.size() > 0
&& request.resource.data.message.size() <= 1000
&& request.resource.data.type in ['general', 'bug', 'feature', 'other']
&& (
!('email' in request.resource.data)
|| request.resource.data.email == null
|| (request.resource.data.email is string
&& request.resource.data.email.size() <= 254
&& request.resource.data.email.matches('^[^@ ]+@[^@ ]+[.][^@ ]+$'))
);
allow read, update, delete: if false; // Only accessible via Admin SDK/Console
}
}
}