-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathfirestore.rules
More file actions
490 lines (458 loc) · 25.7 KB
/
Copy pathfirestore.rules
File metadata and controls
490 lines (458 loc) · 25.7 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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
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
// `signalType` is deliberately NOT required, and not bounded
// either. Signal types were folded into the help-tag vocabulary
// and nothing writes the field any more, but builds released
// before that still send it — mandating or range-checking a
// retired field would only ever break an old client.
&& isValidUrgency()
&& isValidHelpNeededTags()
&& isValidAnimalType();
}
// Bounds the help tags, but does NOT require the field — same reasoning as
// isValidUrgency. Builds released before the tag system are still in the
// wild and create signals with none; the fan-out defaults those to the
// fallback tag, so they still reach people. Requiring it here would break
// signal creation for everyone who has not updated.
//
// Tighten to mandatory only once adoption of the tagged build is high
// enough — that is step 3 of the rollout, and it is a production deploy.
// This did NOT change when signal types were retired: tags are now the only
// thing describing what a signal needs, which makes requiring them more
// tempting and no less breaking. An old build that cannot create a signal
// at all is a worse outcome than one whose signals default to `rescue`.
//
// The size cap is a reach limit as much as a priority limit: tagging a
// signal with the whole vocabulary would make it match every user. The
// codes themselves are NOT validated against an allow-list, deliberately —
// a newer client may introduce a code this deployed ruleset has never heard
// of, and rejecting it would break the new build instead of the old one.
function isValidHelpNeededTags() {
return !('helpNeededTags' in request.resource.data)
|| (request.resource.data.helpNeededTags is list
&& request.resource.data.helpNeededTags.size() > 0
&& request.resource.data.helpNeededTags.size() <= 3);
}
// Optional for the same reason, and left unconstrained beyond being a
// string so a future species cannot be rejected by an old ruleset.
function isValidAnimalType() {
return !('animalType' in request.resource.data)
|| (request.resource.data.animalType is string
&& request.resource.data.animalType.size() > 0
&& request.resource.data.animalType.size() <= 32);
}
// Bounds the urgency level, but does NOT require the field.
//
// Optional on purpose: builds released before the urgency system are still
// in the wild and create signals with no `urgency` at all. Making it
// mandatory would break signal creation for every user who has not
// updated, which is not a trade worth making for a field the client
// back-fills on read anyway.
function isValidUrgency() {
return !('urgency' in request.resource.data)
|| (request.resource.data.urgency is int
&& request.resource.data.urgency >= 0
&& request.resource.data.urgency <= 2);
}
// Validates a comment create (M-1): author pinned to the caller. Covers all
// three comment shapes — user text comments, and the `status_change` /
// `urgency_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 a signal-timeline event create (master spec 4.6).
//
// Events live in their own subcollection rather than in `comments` because
// they answer to different rules: a comment is user-authored and its text is
// the payload, while an event is a record of something that happened to the
// signal and its shape is fixed per `type`. Keeping them apart is what lets
// this function validate a CLOSED vocabulary instead of accumulating another
// "only when present" clause per event type, the way isCommentCreate() had
// to for the two system shapes it still has to tolerate.
//
// The type list is duplicated from SignalEventType (lib/src/models/signal_event.dart)
// and guarded by test/signal_event_vocabulary_guard_test.dart. A type the app
// can write but the rules reject fails loudly (the write is denied); a type
// the rules accept but the app cannot read fails SILENTLY — the row just
// never appears in anyone's history.
//
// A `status_change` event does not by itself authorise the status change:
// the signal write travels through isStatusOnlyUpdate() separately.
function isSignalEventCreate() {
return request.auth != null
&& request.resource.data.actor == userDoc()
&& request.resource.data.createdAt is timestamp
&& isValidEventNote()
&& (
(request.resource.data.type == 'status_change'
&& isValidLevel('oldStatus') && isValidLevel('newStatus'))
|| (request.resource.data.type == 'urgency_change'
&& isValidLevel('oldUrgency') && isValidLevel('newUrgency'))
);
}
// The update note is MANDATORY on an event (spec 4.6: "Every status change
// requires an update note"), unlike `text` on a comment, which is optional
// because the legacy system shapes carry none.
//
// 500 is mirrored by the input formatter on the note dialog — see the
// field-length invariant in docs/SPECIFICATION.md 12.
function isValidEventNote() {
return request.resource.data.note is string
&& request.resource.data.note.size() > 0
&& request.resource.data.note.size() <= 500;
}
// A status or urgency code. Same 0..2 bound as the signal fields they
// describe, so an event can never claim a transition the signal itself
// could not hold.
// `get` with an out-of-range default so an absent field fails the bound
// rather than needing a separate `in` check.
function isValidLevel(field) {
let value = request.resource.data.get(field, -1);
return value is int && value >= 0 && value <= 2;
}
// 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.
//
// `urgency` is DELIBERATELY absent from the affectedKeys allow-list, and
// must stay absent. The spec restricts marking a signal Red to the case
// holder, a moderator or an admin; with no moderator/admin roles yet, that
// means the reporter alone. This omission is the entire enforcement of
// that rule — adding 'urgency' here would let any signed-in user escalate
// a stranger's case to Red Alert (or quietly de-escalate a real one).
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: if request.auth != null && request.auth.uid == userId;
// create/update and delete are split on purpose. `isValidHelperPrefs`
// dereferences `request.resource.data`, which is **null on a delete** — a
// combined `allow write` therefore errors and denies every delete,
// including the owner's own. That breaks `detachAnonymousData`, which
// deletes this doc and `userLocations/{uid}` in one try block: the first
// delete throws, the second never runs, and the abandoned uid keeps a live
// location doc that the fan-out still treats as a candidate.
allow create, update: if request.auth != null
&& request.auth.uid == userId
&& isValidHelperPrefs();
allow delete: if request.auth != null && request.auth.uid == userId;
}
// Size caps on the tag/species preference lists.
//
// Only the owner can write here, so this is not an authorization boundary —
// it is a bound on how far one account can inflate the fan-out's work.
// Those lists are read for every candidate on every signal, and nothing
// else stops a client writing ten thousand entries into one.
//
// Codes are not checked against an allow-list, for the same reason as
// isValidHelpNeededTags: a newer app build must not be rejected by an older
// deployed ruleset. An unrecognised code simply never matches.
function isValidHelperPrefs() {
return isBoundedCodeList('helperTags', 32)
&& isBoundedCodeList('animalTypes', 32);
}
// `field` under notificationPreferences is absent, or a list of at most
// `max` entries. Absent is always allowed: every writer of this document
// uses a merged partial write, so most updates touch none of these.
function isBoundedCodeList(field, max) {
return !('notificationPreferences' in request.resource.data)
|| !(field in request.resource.data.notificationPreferences)
|| (request.resource.data.notificationPreferences[field] is list
&& request.resource.data.notificationPreferences[field].size() <= max);
}
// 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
// Both field shapes are allowed, and neither is required.
// Builds released before the tag vocabulary write
// `signalType`; newer ones write `helpNeededTags`. A phased
// release means both are in the wild for months, and this
// rule is the *client* write path for the arrival catch-up
// — rejecting the old shape makes that inbox entry vanish
// silently, because NearbySignalChecker swallows the error.
// Tighten to helpNeededTags-only once old builds are gone.
// Tracking: HelpAPaw/Flutter#70.
&& request.resource.data.keys().hasOnly([
'type', 'title', 'body', 'read', 'signalId',
'signalTitle', 'helpNeededTags', '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
// Bounded if present, never required — see the allow-list
// above. Codes are not checked against a list: a newer
// client may know one this deployed ruleset does not, and
// rejecting it would break the new build rather than the
// old one. Same reasoning as isValidHelpNeededTags.
&& (!('helpNeededTags' in request.resource.data)
|| (request.resource.data.helpNeededTags is list
&& request.resource.data.helpNeededTags.size() <= 3))
&& (!('signalType' in request.resource.data)
|| 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.
//
// `isValidUrgency()` applies to BOTH branches. The reporter branch
// otherwise accepts any field at any value, and an out-of-range urgency
// is not a client-side cosmetic problem: the server reads it raw, so
// `urgency: 42` makes every write look like an escalation
// (`42 > 2`) and wakes every subscriber with a "Updated" push.
//
// The tag/species validators apply to both branches for the same reason,
// and the cap especially: `helpNeededTags` is a *reach* limit, so leaving
// update unvalidated means a reporter can create a compliant signal and
// then widen it to the whole vocabulary, matching every user in the
// fan-out's set intersection. The edit screen writes both fields on
// update, so this is the path that field actually travels.
allow update: if request.auth != null
&& isValidUrgency()
&& isValidHelpNeededTags()
&& isValidAnimalType()
&& (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);
}
// Signal timeline (master spec 4.6). See isSignalEventCreate() for why
// this is not in `comments`.
match /events/{eventId} {
allow read: if true; // Public read, like the signal it describes
allow create: if isSignalEventCreate();
// An event is a record of what happened; nobody edits history.
allow update: if false;
// KNOWN GAP: the reporter can delete individual events, so the history
// is tamper-evident at best. This exists only because the delete-signal
// cascade runs on the CLIENT — it has to be able to empty the
// subcollection, or deleting a signal orphans it forever. The fix is to
// move deletion server-side (Admin SDK recursive delete, like
// deleteAccount), after which this becomes `if false`.
// Tracking: HelpAPaw/Flutter#68.
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
&& isValidUrgency()
&& isValidHelpNeededTags()
&& isValidAnimalType()
&& (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);
}
match /events/{eventId} {
allow read: if true;
allow create: if isSignalEventCreate();
allow update: if false;
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
}
}
}