-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfirestore.rules
More file actions
228 lines (211 loc) · 14.5 KB
/
Copy pathfirestore.rules
File metadata and controls
228 lines (211 loc) · 14.5 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
rules_version = '2';
// Data model:
// parents/{parentUid} - one doc per parent account
// parents/{parentUid}/linkedDevices/{deviceUid} - a kid device that claimed a pairing code for
// this parent (#18, #39); writable only by the
// device that already claimed that code
// parents/{parentUid}/children/{childId} - a child profile + limits;
// the parent's own self-tracked
// profile (#8) always lives at
// the fixed id "self"
// parents/{parentUid}/children/{childId}/dailyStats/{d} - per-day usage, d = yyyy-MM-dd
// pairingCodes/{code} - short-lived pairing handshake
//
// The kid app never has a normal account: it signs in anonymously and "claims"
// a pairing code, which atomically stamps its anonymous uid onto the child
// document as deviceUid (the child claim below is bound to that code - a device
// can't claim a child without having claimed its unexpired code in the same
// transaction). After that, these rules let that specific uid read the child's
// limits, write that child's dailyStats (validated), and write the fields in the
// "parent mode" allowlist below.
//
// IMPORTANT - what this does and doesn't enforce. The family passcode and the
// "requests are never self-granted" behavior are CLIENT-SIDE gates: the paired kid
// device holds its own Firebase credentials, and the "parent mode" rule below has no
// passcode check (Firestore rules can't run PBKDF2), so anything on that allowlist -
// including locked, limits, bedtime, and temporaryUnlockUntilMs - is writable by
// whoever can extract that device's token. The real boundaries are: (1) one family's
// data from another's, and (2) the kid device only touching its own child doc. A kid
// with physical access can already disable the accessibility service or uninstall the
// app, so this is documented as a deterrent, not a lock. Closing it fully would need
// a server (e.g. a Cloud Function verifying the passcode with a lockout), which this
// project deliberately doesn't have.
service cloud.firestore {
match /databases/{database}/documents {
// True for a device that paired with this parent (see linkedDevices below). The old
// linkedDeviceUids array on the parent doc is still honored for devices paired before
// that changed, but nothing except the parent themself can write it any more - it used to
// be writable by ANY signed-in user (they could add themselves to another family's list
// and read that parent's self-tracked stats), which is what #39 fixed.
function isLinkedDevice(parentUid) {
return request.auth != null
&& (exists(/databases/$(database)/documents/parents/$(parentUid)/linkedDevices/$(request.auth.uid))
|| request.auth.uid in
get(/databases/$(database)/documents/parents/$(parentUid)).data.get('linkedDeviceUids', []));
}
// In-app feedback (#22): write-only. Nobody, not even the submitter, can read it back
// through the app - it's reviewed via the Firebase console, which has admin access
// that bypasses these rules entirely.
match /feedback/{feedbackId} {
allow create: if request.auth != null
&& request.auth.uid == request.resource.data.parentUid
&& request.resource.data.keys().hasOnly(['parentUid', 'text', 'appVersion', 'device', 'createdAt'])
&& request.resource.data.text is string
&& request.resource.data.text.size() > 0 && request.resource.data.text.size() <= 4000
&& request.resource.data.get('appVersion', '') is string
&& request.resource.data.get('appVersion', '').size() <= 64
&& request.resource.data.get('device', '') is string
&& request.resource.data.get('device', '').size() <= 200
&& request.resource.data.createdAt == request.time;
allow read, update, delete: if false;
}
match /pairingCodes/{code} {
// A code is only readable while it's live (unused and inside its TTL), or by the
// device that claimed it. Previously any signed-in user could read ANY code doc,
// ever - 6 digits is enumerable, and each doc leaks parentUid/childId - so this is
// what keeps the code space from doubling as a directory of families.
allow get: if request.auth != null
&& ((resource.data.used == false
&& request.time < resource.data.createdAt + duration.value(30, 'm'))
|| resource.data.get('claimedByUid', '') == request.auth.uid);
allow list: if false;
// createdAt must be the server's own clock (via FieldValue.serverTimestamp()),
// not a client-supplied value, so a claimant can't extend its own window.
allow create: if request.auth != null
&& request.auth.uid == request.resource.data.parentUid
&& request.resource.data.keys().hasOnly(['parentUid', 'childId', 'used', 'createdAt'])
&& request.resource.data.used == false
&& request.resource.data.createdAt == request.time;
// A code can only be claimed within PAIRING_CODE_TTL of creation. This bounds
// how long a guessed/brute-forced code is actually exploitable - see #2.
allow update: if request.auth != null
&& resource.data.used == false
&& request.time < resource.data.createdAt + duration.value(30, 'm')
&& request.resource.data.used == true
&& request.resource.data.parentUid == resource.data.parentUid
&& request.resource.data.childId == resource.data.childId
&& request.resource.data.claimedByUid == request.auth.uid;
allow delete: if false;
}
match /parents/{parentUid} {
allow read, write: if request.auth != null && request.auth.uid == parentUid;
// Which kid devices are linked to this parent (so they can read the parent's self-tracked stats if
// the parent opts in - #18, visible by default, not a separate opt-in). One doc per device, keyed by
// its uid. A device can only create ITS OWN doc, and only while naming a pairing code that it has
// itself claimed from THIS parent, so nobody can link themselves to a family they haven't paired
// with. (getAfter, not get, so this also holds when the write is part of a batch or transaction;
// the client writes it just after the claim, deliberately NOT inside it - a refusal here must
// never fail the pairing itself, which is what it did before.) Only the parent can read or
// remove them. (The parent doc itself is readable only by the parent - the passcode hash/salt live
// there - and get()/exists() calls in these rules bypass permissions without exposing contents.)
match /linkedDevices/{deviceUid} {
allow read, write: if request.auth != null && request.auth.uid == parentUid;
allow create: if request.auth != null
&& request.auth.uid == deviceUid
&& request.resource.data.keys().hasOnly(['code', 'createdAt'])
&& request.resource.data.code is string
&& request.resource.data.createdAt == request.time
&& getAfter(/databases/$(database)/documents/pairingCodes/$(request.resource.data.code)).data.claimedByUid == request.auth.uid
&& getAfter(/databases/$(database)/documents/pairingCodes/$(request.resource.data.code)).data.parentUid == parentUid
&& getAfter(/databases/$(database)/documents/pairingCodes/$(request.resource.data.code)).data.used == true;
}
match /children/{childId} {
allow read, write: if request.auth != null && request.auth.uid == parentUid;
// The paired kid device may read its own child doc (to pick up limit changes).
allow get: if request.auth != null && resource.data.get('deviceUid', '') == request.auth.uid;
// Any device linked to this parent (see linkedDeviceUids above) may also read
// the parent's own self-tracked profile - see #18. isSelf profiles always live
// at the fixed id "self" (FirestorePaths.SELF_CHILD_ID) specifically so a linked
// device can read this by direct path; it has no permission to list/query this
// collection (see the claimedDeviceCannotListSiblingChildren test).
allow get: if request.auth != null && resource.data.isSelf == true
&& isLinkedDevice(parentUid);
// One-time claim: only allowed while deviceUid is still unset, only deviceUid/paired
// may change, and only as part of the same transaction that claims this child's own
// live pairing code (getAfter sees the post-transaction code doc). So a stranger
// can't claim an unpaired child without its unexpired code.
//
// get('deviceUid', null), not resource.data.deviceUid: reading a field that ISN'T
// THERE is an evaluation error, which denies the claim outright. A never-paired
// child written without the key at all (the iOS parent app used to drop nil fields)
// would otherwise be impossible to pair, with no way to tell that apart from a
// genuinely refused claim.
allow update: if request.auth != null
&& resource.data.get('deviceUid', null) == null
&& request.resource.data.deviceUid == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deviceUid', 'paired'])
&& getAfter(/databases/$(database)/documents/pairingCodes/$(resource.data.pairingCode)).data.claimedByUid == request.auth.uid
&& getAfter(/databases/$(database)/documents/pairingCodes/$(resource.data.pairingCode)).data.used == true
&& getAfter(/databases/$(database)/documents/pairingCodes/$(resource.data.pairingCode)).data.childId == childId
&& getAfter(/databases/$(database)/documents/pairingCodes/$(resource.data.pairingCode)).data.parentUid == parentUid;
// "Parent mode" on an already-paired kid device: the linked device may
// change its own limits/lock state (never the passcode fields or pairing
// metadata - only the parent account can change those).
allow update: if request.auth != null
&& resource.data.deviceUid == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['dailyLimitMinutes', 'appLimits', 'locked', 'dailyUnlockGoal',
'bedtimeStartMinutes', 'bedtimeEndMinutes', 'blockedDomains',
'temporaryUnlockUntilMs', 'requestedExtraMinutes', 'alwaysAllowedPackages',
'alwaysAllowedContacts', 'trackUnlocks', 'trackNotifications',
'showUnlocksOnKid', 'showNotificationsOnKid', 'trackWebsites',
'excludedFromTotalPackages', 'focusMode', 'focusProfile',
'focusAllowedPackages', 'travelAllowedPackages']);
// Negotiated limits (#14) and "more time" requests (#23): the linked device may
// write these with no passcode/parent-mode gate at all - deliberately separate from
// the rule above, since neither is ever applied on its own. Only the parent (the
// top-level "read, write" rule, or "parent mode" above) can turn a proposal into a
// real dailyLimitMinutes/appLimits change, or a time request into a granted
// temporaryUnlockUntilMs.
allow update: if request.auth != null
&& resource.data.deviceUid == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['proposedDailyLimitMinutes', 'proposedAppLimits', 'requestedExtraMinutes']);
// What a paired kid device may write to its own dailyStats: a fixed set of fields,
// with types and sizes bounded, so a device can't stuff arbitrary or huge data
// (which would also poison the parent app's parsing, and cost the operator money).
function validDeviceStats(date) {
let d = request.resource.data;
return date.matches('[0-9]{4}-[0-9]{2}-[0-9]{2}')
&& d.keys().hasOnly(['totalScreenTimeMs', 'unlockCount', 'appUsage', 'lastSyncedAtMs',
'notificationCount', 'notificationsByApp', 'unlockFirstApps', 'websiteCounts'])
&& d.get('totalScreenTimeMs', 0) is int
&& d.get('unlockCount', 0) is int
&& d.get('lastSyncedAtMs', 0) is int
&& d.get('notificationCount', 0) is int
&& d.get('appUsage', []) is list && d.get('appUsage', []).size() <= 500
&& d.get('notificationsByApp', []) is list && d.get('notificationsByApp', []).size() <= 500
&& d.get('unlockFirstApps', []) is list && d.get('unlockFirstApps', []).size() <= 500
&& d.get('websiteCounts', []) is list && d.get('websiteCounts', []).size() <= 500;
}
// The apps installed on the kid's device (name + package only), so the parent app can offer a
// limit for every app, not only ones already used. One doc, size-bounded.
function validInstalledApps() {
let d = request.resource.data;
return d.keys().hasOnly(['apps', 'updatedAtMs'])
&& d.apps is list && d.apps.size() <= 500
&& d.get('updatedAtMs', 0) is int;
}
match /deviceInfo/{doc} {
allow read, write: if request.auth != null && request.auth.uid == parentUid;
allow create, update: if request.auth != null
&& doc == 'installedApps'
&& get(/databases/$(database)/documents/parents/$(parentUid)/children/$(childId)).data.deviceUid == request.auth.uid
&& validInstalledApps();
}
match /dailyStats/{date} {
allow read, write: if request.auth != null && request.auth.uid == parentUid;
allow read: if request.auth != null &&
get(/databases/$(database)/documents/parents/$(parentUid)/children/$(childId)).data.deviceUid == request.auth.uid;
allow create, update: if request.auth != null &&
get(/databases/$(database)/documents/parents/$(parentUid)/children/$(childId)).data.deviceUid == request.auth.uid &&
validDeviceStats(date);
// Mirrors the isSelf get() rule above, for the daily numbers behind it (#18).
allow read: if request.auth != null &&
get(/databases/$(database)/documents/parents/$(parentUid)/children/$(childId)).data.isSelf == true &&
isLinkedDevice(parentUid);
}
}
}
}
}