diff --git a/AuthViewModel.js b/AuthViewModel.js index fa1ff6b..e698b42 100644 --- a/AuthViewModel.js +++ b/AuthViewModel.js @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { createUser } from './Models'; +import { createUser, isReservedUsername } from './Models'; import { saveUser, userExists, @@ -13,10 +13,16 @@ import { saveActiveSessionUserID, getActiveSessionUserID, clearActiveSessionUserID, + generateSalt, + hashPassword, } from './StorageExtension'; import { hasModerationMatch } from './ContentModeration'; import uuid from 'react-native-uuid'; +// Field length caps — mirrored in firestore.rules so client and rules agree. +const MAX_USERNAME_LEN = 64; +const MAX_DISPLAY_NAME_LEN = 64; + export const useAuthViewModel = () => { const [currentUser, setCurrentUser] = useState(null); const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -62,12 +68,28 @@ export const useAuthViewModel = () => { setAuthError('Username must be at least 3 characters'); return; } + if (normalizedUsername.length > MAX_USERNAME_LEN) { + setAuthError(`Username must be at most ${MAX_USERNAME_LEN} characters`); + return; + } + if (normalizedDisplayName.length > MAX_DISPLAY_NAME_LEN) { + setAuthError(`Display name must be at most ${MAX_DISPLAY_NAME_LEN} characters`); + return; + } if (hasModerationMatch(normalizedUsername)) { setAuthError('Username contains disallowed language'); return; } - if (password.length < 6) { - setAuthError('Password must be at least 6 characters'); + if (isReservedUsername(normalizedUsername)) { + setAuthError('This username is reserved and cannot be registered.'); + return; + } + if (password.length < 10) { + setAuthError('Password must be at least 10 characters'); + return; + } + if (password.length > 256) { + setAuthError('Password is too long'); return; } if (password !== confirmPassword) { @@ -86,11 +108,15 @@ export const useAuthViewModel = () => { await new Promise((r) => setTimeout(r, 500)); + const passwordSalt = generateSalt(); + const passwordHash = await hashPassword(password, passwordSalt); + const newUser = createUser({ id: uuid.v4(), username: normalizedUsername, displayName: normalizedDisplayName, - password, + passwordHash, + passwordSalt, role: 'user', bio: '', profileImage: null, diff --git a/DiscussionDetailView.js b/DiscussionDetailView.js index b7e580a..12ec35f 100644 --- a/DiscussionDetailView.js +++ b/DiscussionDetailView.js @@ -13,12 +13,51 @@ import { Alert, Modal, Platform, + Linking, } from 'react-native'; import Markdown from 'react-native-markdown-display'; import { createComment } from './Models'; import { hasModerationMatch } from './ContentModeration'; +import { isSafeUrl, isHttpUrl } from './urlSafety'; import uuid from 'react-native-uuid'; +const COMMENT_MAX_LENGTH = 5000; + +// Only allow http(s) image handlers; everything else is dropped. +const SAFE_IMAGE_HANDLERS = ['http://', 'https://']; + +// Open links only when their scheme is allowlisted; never hand a +// javascript:/data:/vbscript:/file: URL to Linking.openURL. +const handleMarkdownLinkPress = (url) => { + if (isSafeUrl(url)) { + Linking.openURL(url).catch(() => {}); + } + // Always return false so the library never opens the URL itself. + return false; +}; + +// Render rules that neutralize unsafe link/image hrefs while leaving normal +// http(s) links and images fully functional. +const safeMarkdownRules = { + link: (node, children, parent, styles) => { + const href = node.attributes?.href; + if (!isSafeUrl(href)) { + // Render the link text inline as plain (inert) text. + return {children}; + } + return ( + handleMarkdownLinkPress(href)}> + {children} + + ); + }, + image: (node, children, parent, styles) => { + const src = node.attributes?.src; + if (!isHttpUrl(src)) return null; + return ; + }, +}; + const toImageURI = (image) => { if (!image) return null; if (typeof image === 'string') return `data:image/jpeg;base64,${image}`; @@ -196,7 +235,13 @@ const DiscussionDetailView = ({ discussion, viewModel, currentUser, onBack, onOp {/* Title & Content */} {liveDiscussion.title} - + {liveDiscussion.content || ''} @@ -354,6 +399,7 @@ const DiscussionDetailView = ({ discussion, viewModel, currentUser, onBack, onOp value={newCommentText} onChangeText={setNewCommentText} multiline={false} + maxLength={COMMENT_MAX_LENGTH} editable={permissions.canPostOrComment} /> new Date().toISOString(); const isForumExpired = (forum) => new Date(forum.expiresAt).getTime() <= Date.now(); @@ -358,7 +355,7 @@ export const useDiscussionViewModel = () => { if (!userID) return false; const username = knownUsers[userID]; if (!username) return false; - return ADMIN_IDS.map((id) => id.toLowerCase()).includes(String(username).trim().toLowerCase()); + return isAdminUsername(username); }, [knownUsers]); const getPermissionSummary = useCallback((user) => { @@ -376,7 +373,7 @@ export const useDiscussionViewModel = () => { } const role = String(user?.role || 'user').trim().toLowerCase(); const username = String(user?.username || '').trim().toLowerCase(); - const isAdmin = ADMIN_IDS.map((id) => id.toLowerCase()).includes(username); + const isAdmin = isAdminUsername(username); const forumModerators = Array.isArray(user?.forumModerators) ? user.forumModerators : []; const isModerator = selectedForum?.id ? forumModerators.includes(selectedForum.id) : false; const persistedMutedUntil = user?.mutedUntil || null; @@ -413,6 +410,10 @@ export const useDiscussionViewModel = () => { enqueueNotification('Forum end date/time must be in the future.'); return false; } + if (title.trim().length > MAX_TITLE_LEN) { + enqueueNotification(`Forum title must be at most ${MAX_TITLE_LEN} characters.`, 'danger'); + return false; + } const titleResult = moderateText(title.trim(), blockedWords); if (titleResult.blockedCount > 0) { enqueueNotification('Forum title contains blocked language.', 'danger'); @@ -468,6 +469,28 @@ export const useDiscussionViewModel = () => { enqueueNotification('This forum is read-only.', 'info'); return; } + // Input validation (length caps mirror firestore.rules). + if ((title || '').trim().length > MAX_TITLE_LEN) { + enqueueNotification(`Title must be at most ${MAX_TITLE_LEN} characters.`, 'danger'); + return false; + } + if ((description || '').trim().length > MAX_DESCRIPTION_LEN) { + enqueueNotification(`Description must be at most ${MAX_DESCRIPTION_LEN} characters.`, 'danger'); + return false; + } + if ((content || '').trim().length > MAX_CONTENT_LEN) { + enqueueNotification(`Content must be at most ${MAX_CONTENT_LEN} characters.`, 'danger'); + return false; + } + const tagList = Array.isArray(tags) ? tags : []; + if (tagList.length > MAX_TAGS) { + enqueueNotification(`A post may have at most ${MAX_TAGS} tags.`, 'danger'); + return false; + } + if (tagList.some((tag) => String(tag || '').length > MAX_TAG_LEN)) { + enqueueNotification(`Each tag must be at most ${MAX_TAG_LEN} characters.`, 'danger'); + return false; + } const titleResult = moderateText(title.trim(), blockedWords); const descriptionResult = moderateText((description || '').trim(), blockedWords); const contentResult = moderateText(content.trim(), blockedWords); @@ -718,6 +741,8 @@ export const useDiscussionViewModel = () => { deletedByName: actor?.username || 'moderator', }; setDeletedDiscussions((prev) => [archived, ...prev]); + // Targeted remote delete (write-only sync no longer removes diffed docs). + deleteDiscussionRemote(discussionID); enqueueNotification('Post archived by moderation team.', 'danger'); return true; }, [enqueueNotification, getPermissionSummary]); @@ -852,13 +877,15 @@ export const useDiscussionViewModel = () => { } setDiscussions((prev) => { - const removedCount = prev.filter((discussion) => discussion.authorID === userID).length; + const removed = prev.filter((discussion) => discussion.authorID === userID); enqueueNotification( - removedCount > 0 - ? `User banned and ${removedCount} post(s) removed.` + removed.length > 0 + ? `User banned and ${removed.length} post(s) removed.` : 'User banned by admin.', 'danger' ); + // Targeted remote deletes for each removed post. + removed.forEach((discussion) => deleteDiscussionRemote(discussion.id)); return prev.filter((discussion) => discussion.authorID !== userID); }); return true; @@ -1072,12 +1099,14 @@ export const useDiscussionViewModel = () => { return normalizeName(discussion.authorName) === normalizedAuthorName; }; - const removedCount = prev.filter(matchesTarget).length; + const removed = prev.filter(matchesTarget); enqueueNotification( - removedCount > 0 - ? `${removedCount} post(s) removed for this user.` + removed.length > 0 + ? `${removed.length} post(s) removed for this user.` : 'No posts found for this user.' ); + // Targeted remote deletes for each removed post. + removed.forEach((discussion) => deleteDiscussionRemote(discussion.id)); return prev.filter((discussion) => !matchesTarget(discussion)); }); return true; @@ -1176,12 +1205,20 @@ export const useDiscussionViewModel = () => { const remaining = forums.filter((forum) => forum.id !== forumID); setForums(remaining); + // Targeted remote delete of the forum doc. + deleteForumRemote(forumID); if (selectedForumID === forumID) { setSelectedForumID(remaining[0]?.id || null); } - setDiscussions((prev) => prev.filter((discussion) => discussion.forumID !== forumID)); + setDiscussions((prev) => { + // Targeted remote deletes for each child post of the removed forum. + prev + .filter((discussion) => discussion.forumID === forumID) + .forEach((discussion) => deleteDiscussionRemote(discussion.id)); + return prev.filter((discussion) => discussion.forumID !== forumID); + }); enqueueNotification('Forum deleted with all its posts.', 'danger'); return true; }, [enqueueNotification, getPermissionSummary, forums, selectedForumID]); diff --git a/ForumHomeView.js b/ForumHomeView.js index f75dd95..8836d23 100644 --- a/ForumHomeView.js +++ b/ForumHomeView.js @@ -1442,6 +1442,7 @@ const ForumHomeView = ({ authVM, currentUser, onLogout, newUserNotice, clearNewU placeholderTextColor="#B6BFCC" value={forumTitle} onChangeText={setForumTitle} + maxLength={200} /> {forumTitleHasBlockedLanguage ? ( Blocked language detected in forum title. diff --git a/LexicalMarkdownEditor.js b/LexicalMarkdownEditor.js index d200825..09ff48e 100644 --- a/LexicalMarkdownEditor.js +++ b/LexicalMarkdownEditor.js @@ -1,7 +1,7 @@ import React from 'react'; import { TextInput } from 'react-native'; -const LexicalMarkdownEditor = ({ value, onChange, placeholder, style }) => { +const LexicalMarkdownEditor = ({ value, onChange, placeholder, style, maxLength = 20000 }) => { return ( { placeholderTextColor="#B6BFCC" value={value} onChangeText={onChange} + maxLength={maxLength} multiline textAlignVertical="top" /> diff --git a/LexicalMarkdownEditor.web.js b/LexicalMarkdownEditor.web.js index a204767..3b25632 100644 --- a/LexicalMarkdownEditor.web.js +++ b/LexicalMarkdownEditor.web.js @@ -1,7 +1,7 @@ import React from 'react'; import { TextInput } from 'react-native'; -const LexicalMarkdownEditor = ({ value, onChange, placeholder, style }) => { +const LexicalMarkdownEditor = ({ value, onChange, placeholder, style, maxLength = 20000 }) => { return ( { placeholderTextColor="#B6BFCC" value={value} onChangeText={onChange} + maxLength={maxLength} multiline textAlignVertical="top" autoCorrect={false} diff --git a/Models.js b/Models.js index 53ba130..bdf07e3 100644 --- a/Models.js +++ b/Models.js @@ -1,11 +1,40 @@ - + + +// Centralized client-side admin allowlist. +// +// SECURITY NOTE: this is a client-side-only convenience flag. The admin check +// (DiscussionViewModel.getPermissionSummary) runs entirely in client code and +// is trivially bypassable from the JS console / direct Firestore writes. It is +// NOT a security boundary. Authoritative admin/role/ban/moderation enforcement +// requires a server identity (Firebase Auth or trusted backend) and is +// DEFERRED. We additionally reserve these usernames at signup (see +// isReservedUsername) so an attacker can't simply register one and inherit the +// flag — but that too is only a client-side guard. +export const ADMIN_USERNAMES = [ + 'varun', + 'ekansh_mishra', + 'si_yuan', + 'zwe', + 'paul', + 'joel', + 'julianteh', + 'rogeryeo', +]; + +export const isAdminUsername = (username) => + ADMIN_USERNAMES.includes(String(username || '').trim().toLowerCase()); + +// Usernames that may not be registered (currently the admin allowlist). +export const isReservedUsername = (username) => + ADMIN_USERNAMES.includes(String(username || '').trim().toLowerCase()); /** * @typedef {Object} User * @property {string} id * @property {string} username * @property {string} displayName - * @property {string} password + * @property {string} passwordHash - SHA-256(salt + password); never plaintext + * @property {string} passwordSalt - per-user random salt * @property {'admin'|'user'} role * @property {string} displayName - Display name for user * @property {string} bio @@ -15,11 +44,17 @@ * @property {string[]} forumModerators - Array of forum IDs where user is a moderator * @property {string} createdAt - ISO date string */ +// NOTE: passwords are never stored in plaintext. The caller (AuthViewModel) +// hashes the password client-side and passes passwordHash + passwordSalt. +// Client-side hashing prevents plaintext-credential disclosure/reuse if the +// (world-readable) users collection leaks, but it is NOT a substitute for +// server-side authentication — that is DEFERRED (no server identity yet). export const createUser = ({ id, username, displayName = '', - password, + passwordHash = '', + passwordSalt = '', role = 'user', bio = '', profileImage = null, @@ -30,8 +65,8 @@ export const createUser = ({ }) => ({ id, username, - displayName, - password, + passwordHash, + passwordSalt, role, displayName: displayName || username, bio, diff --git a/NewDiscussionView.js b/NewDiscussionView.js index e37268e..98002f6 100644 --- a/NewDiscussionView.js +++ b/NewDiscussionView.js @@ -9,11 +9,54 @@ import { Image, StyleSheet, SafeAreaView, + Linking, } from 'react-native'; import Markdown from 'react-native-markdown-display'; import LexicalMarkdownEditor from './LexicalMarkdownEditor'; import MediaPicker from './MediaPicker'; import { hasModerationMatch } from './ContentModeration'; +import { isSafeUrl, isHttpUrl } from './urlSafety'; + +// Caps mirror the view-model validation limits (MAX_TITLE_LEN/MAX_DESCRIPTION_LEN/ +// MAX_CONTENT_LEN and MAX_TAGS * MAX_TAG_LEN) so the UI never blocks a valid post. +const TITLE_MAX_LENGTH = 200; +const DESCRIPTION_MAX_LENGTH = 1000; +const TAGS_MAX_LENGTH = 500; // ~10 tags x 40 chars plus separators +const CONTENT_MAX_LENGTH = 20000; + +// Only allow http(s) image handlers; everything else is dropped. +const SAFE_IMAGE_HANDLERS = ['http://', 'https://']; + +// Open links only when their scheme is allowlisted; never hand a +// javascript:/data:/vbscript:/file: URL to Linking.openURL. +const handleMarkdownLinkPress = (url) => { + if (isSafeUrl(url)) { + Linking.openURL(url).catch(() => {}); + } + // Always return false so the library never opens the URL itself. + return false; +}; + +// Render rules that neutralize unsafe link/image hrefs while leaving normal +// http(s) links and images fully functional. +const safeMarkdownRules = { + link: (node, children, parent, styles) => { + const href = node.attributes?.href; + if (!isSafeUrl(href)) { + return {children}; + } + return ( + handleMarkdownLinkPress(href)}> + {children} + + ); + }, + image: (node, children, parent, styles) => { + const src = node.attributes?.src; + if (!isHttpUrl(src)) return null; + return ; + }, +}; const NewDiscussionView = ({ viewModel, currentUser, onDismiss }) => { const [title, setTitle] = useState(''); @@ -120,6 +163,7 @@ const NewDiscussionView = ({ viewModel, currentUser, onDismiss }) => { placeholderTextColor="#B6BFCC" value={title} onChangeText={setTitle} + maxLength={TITLE_MAX_LENGTH} /> {hasModerationMatch(title, viewModel.blockedWords) ? ( Blocked language detected in title. @@ -134,6 +178,7 @@ const NewDiscussionView = ({ viewModel, currentUser, onDismiss }) => { placeholderTextColor="#B6BFCC" value={description} onChangeText={setDescription} + maxLength={DESCRIPTION_MAX_LENGTH} /> {hasModerationMatch(description, viewModel.blockedWords) ? ( Blocked language detected in description. @@ -153,11 +198,18 @@ const NewDiscussionView = ({ viewModel, currentUser, onDismiss }) => { placeholder="Enter some text..." value={content} onChange={setContent} + maxLength={CONTENT_MAX_LENGTH} /> {showMarkdownPreview ? ( Preview - + {content.trim() || '*Nothing to preview yet.*'} @@ -176,6 +228,7 @@ const NewDiscussionView = ({ viewModel, currentUser, onDismiss }) => { value={tags} onChangeText={setTags} autoCapitalize="none" + maxLength={TAGS_MAX_LENGTH} /> {hasModerationMatch(tags, viewModel.blockedWords) ? ( Blocked language detected in tags. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..6dde469 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,105 @@ +# Geek-Collab Security Audit + +**Scope:** Static read-only review of the Geek-Collab React Native / Expo + Firebase Firestore forum app. +**Constraint:** Harden in place — **no Firebase Auth migration**. Findings are tagged by what is achievable under that constraint. +**Date:** 2026-06-04 +**Auditor role:** Scanner (read-only — no source files modified). + +> Note on tooling: `npm audit` could not run normally (no `node_modules` installed; no `timeout` binary). It was run in `--package-lock-only` mode against `package-lock.json` and the results are included (Finding D-1). The shared task-board tools (TaskGet/TaskUpdate) were not present in this environment, so Task #1 status could not be set programmatically — this is noted for the orchestrator. + +> Note on Firebase config: the `EXPO_PUBLIC_FIREBASE_*` values are intentionally public in a client app and are **not** treated as a vulnerability here. + +--- + +## Summary of findings (ordered by severity) + +| # | Severity | File : area | Issue | Status under scope | +|---|----------|-------------|-------|--------------------| +| 1 | **Critical** | No `firestore.rules` / `storage.rules` anywhere; `firebase.json` has no `firestore`/`storage` block | Every Firestore collection is world-readable **and** world-writable. Any client can read all users (with passwords) and wipe/replace all data. | **Fixable now** (deploy rules) | +| 2 | **Critical** | `Models.js`, `StorageExtension.saveRemoteUsers`, `AuthViewModel.signUp/login` | Passwords stored in **plaintext** in Firestore `users` and in AsyncStorage; login is a client-side `find(u => u.password === input)` string compare. | **Mitigatable** (hash client-side; full fix needs server) | +| 3 | **Critical** | `DiscussionViewModel.js:20-29, 361, 379` | Admin role granted by hardcoded **username allowlist** (`ADMIN_IDS`) checked in client code. Registration is open and username-based, so anyone can register e.g. `paul`/`joel` and become admin. | **Mitigatable** (no real fix without server auth) | +| 4 | **Critical** | `StorageExtension.saveRemoteUsers` / `syncRemoteForumCollection` / `clearForumState` / `deleteAllUsers` | Full-collection overwrite + delete-by-diff sync. Combined with #1, any unauthenticated client can delete or replace the entire `users`, `forums`, and `discussions` collections. | **Mitigatable** (rules limit blast radius; pattern still risky) | +| 5 | **High** | `DiscussionViewModel.js` (all `canModerate`/`isAdmin` gates), `StorageExtension.updateUserBanStatus/MuteStatus/Role` | All authorization (ban, mute, role change, moderation, content deletion) enforced **only in client code**. Trivially bypassed via console or direct Firestore write. | **Deferred — needs server auth** (partially mitigatable via rules) | +| 6 | **High** | `DiscussionDetailView.js:199`, `NewDiscussionView.js:160` | Web XSS: `react-native-markdown-display` renders user markdown with no link/image href sanitizer. `javascript:` / `data:` URLs in links/images are not blocked on web. | **Fixable now** (add `onLinkPress` + render rules) | +| 7 | **High** | `StorageExtension.getRemoteUsers/getAllUsers/getForumState/getRemoteForumCollection` | Unbounded full-collection `getDocs` reads on every load. With #1 this leaks **all** user records (incl. passwords) and enables DoS / cost-amplification by inflating collections. | **Mitigatable** (rules + pagination) | +| 8 | **Medium** | `NewDiscussionView.js`, `DiscussionViewModel.js` | Input validation gaps: no max length on title/content/tags/bio; `videoURL` not scheme-validated; moderation is profanity-only and bypassable (it sanitizes, does not block storage). | **Fixable now** | +| 9 | **Medium** | `package-lock.json` (see D-1) | Dependency CVEs: 34 advisories — 1 critical (`protobufjs`), 14 high (`lodash`, `tar`, `@xmldom/xmldom`, expo CLI chain), 18 moderate, 1 low. Mostly build/tooling, but `lodash` and `markdown-it`/`react-native-markdown-display` reach runtime. | **Fixable now** (upgrade) | +| 10 | **Low** | `StorageExtension.js` AsyncStorage | Plaintext user records (incl. passwords) cached unencrypted in AsyncStorage on every device that loads the app. | **Mitigatable** | + +--- + +## Per-finding detail + +### 1 — Critical — No Firestore/Storage security rules (world read/write) +There is no `firestore.rules` or `storage.rules` file anywhere in the repo, and `firebase.json` defines only `hosting` (no `firestore` or `storage` block). With default open/test rules, every collection — `users` (containing plaintext passwords), `forums`, `discussions`, `forumMeta`, legacy `appState` — is readable and writable by any anonymous client that knows the (public) project ID. +**Fix (now):** Author `firestore.rules`, register it in `firebase.json` under a `firestore` block, and deploy. Since there is no Firebase Auth, rules can't key off `request.auth`, but they can still: deny all reads of the `users` collection (it should never be client-readable once login moves server-side, or at minimum strip the password field), enforce document shape/size limits, reject documents whose `id` doesn't match, and rate-shape writes. This is the single highest-leverage fix and is fully achievable in place. +**Status:** Fixable now. + +### 2 — Critical — Plaintext passwords + client-side password comparison +`Models.createUser` stores `password` as a plaintext field. `StorageExtension.saveRemoteUsers` writes the full user object (`...user`) — including `password` — to Firestore. `AuthViewModel.signUp` passes the raw password; `getUser` (`StorageExtension.js:189-194`) authenticates via `users.find(u => ... && u.password === password)` — a client-side plaintext compare against records pulled from a world-readable collection. Anyone reading the `users` collection harvests every credential. +**Fix:** Full fix requires server-side auth (out of scope). In-place mitigation applied: passwords are hashed client-side with a per-user random salt; only `passwordHash` + `passwordSalt` are persisted; plaintext is stripped before any Firestore/AsyncStorage write and legacy plaintext records are migrated to salted hashes on read/login. The KDF is **PBKDF2-HMAC-SHA256 (150k iterations)** via Web Crypto on the web deployment target (and Node), with a single-round SHA-256 via expo-crypto as a labeled weak fallback only where Web Crypto is unavailable. Hashes are self-describing (`pbkdf2$$`) and verification re-derives with the stored parameters. Minimum password length raised 6 → 10. Combine with Finding #1 (rules now block writing a plaintext `password` key) to stop the `users` collection leaking credentials. +**Status:** Mitigatable (true fix is deferred — needs server auth). **Residual:** verifying against a world-readable document is inherently client-side, and the SHA-256 fallback path remains weak; both are subsumed by the deferred server-auth migration. + +### 3 — Critical — Admin role via hardcoded client-side username allowlist +`DiscussionViewModel.js:20-29` hardcodes `ADMIN_IDS = ['Varun','ekansh_mishra','si_yuan','zwe','paul','joel','julianteh','rogeryeo']`. `isAdmin` is computed as `ADMIN_IDS.includes(username.toLowerCase())` (lines 361, 379) entirely in client code. Registration (`AuthViewModel.signUp`) is open and only checks username availability — but if one of these usernames is ever free, registering it grants admin. More broadly, because the check is client-side, any user can flip `isAdmin`/`canModerate` to `true` in the running JS and perform every privileged action (writes succeed because of #1/#5). +**Fix:** Real fix needs server-enforced identity (deferred). In-place mitigation: move privilege off the username string, and ensure the admin usernames are reserved/cannot be registered. Recognize this is cosmetic without server-side enforcement. +**Status:** Mitigatable (no robust fix without server auth). + +### 4 — Critical — Full-collection overwrite/delete sync +`saveRemoteUsers` (StorageExtension.js:47-73) and `syncRemoteForumCollection` (80-98) read the entire collection, delete every doc whose id is not in the client's in-memory list, then overwrite the rest. `clearForumState` (475-510) and `deleteAllUsers` (321-343) delete entire collections. Any client holding a stale or malicious user list can delete other users' accounts and all discussions in one save. With #1 removing the rules barrier, this is a one-call data-wipe primitive available to anyone. +**Fix:** Firestore rules (#1) constrain who/what can be deleted and enforce that a write only touches the caller's own document. The destructive delete-by-diff sync pattern should be replaced with targeted per-document writes; flag for the fixer. +**Status:** Mitigatable (rules reduce blast radius; pattern itself should be reworked). + +### 5 — High — Authorization enforced only in client code +Every privileged operation in `DiscussionViewModel.js` is gated by `if (!permissions.canModerate) return;` / `if (!permissions.isAdmin) return;` (lines 656, 692, 727, 753, 783, 808, 837, 869, 898...) and ban/mute/role mutations live in `StorageExtension.updateUserBanStatus / updateUserMuteStatus / updateUserRole`. None of this is enforced server-side. A user can call the underlying Firestore writes directly (or edit client state) to ban/mute others, self-promote, or delete content. +**Fix:** Authoritative enforcement requires server-side identity (deferred). Firestore rules can partially constrain the shape and target of writes (e.g. a user may only modify their own user doc; `isBanned`/`role` fields locked from client writes), which mitigates the worst cases without Auth. +**Status:** Deferred — needs server auth (partial mitigation via rules). + +### 6 — High — Web XSS via markdown link/image hrefs +`DiscussionDetailView.js:199` and `NewDiscussionView.js:160` render user-supplied `content` through `` from `react-native-markdown-display` with **no** `onLinkPress` handler and **no** custom `rules`. On web (the app deploys to Firebase Hosting), markdown links and images become real anchors/`img` tags; `[x](javascript:alert(1))` and `data:` URIs are not filtered, enabling script execution on link click / phishing in another user's browser. (On web this is click-dependent — a `javascript:` link fires via `Linking.openURL` on tap — rather than zero-click stored XSS; still High.) +**Fix (now):** Add an `onLinkPress` callback that validates the scheme and returns `false` for anything other than `http:`/`https:`/`mailto:`, and override the `image`/`link` render rules to drop `javascript:`/`data:` hrefs. Apply to both the detail view and the preview. +**Status:** Fixable now. + +### 7 — High — Unbounded full-collection reads (data exposure + DoS/cost) +`getRemoteUsers` / `getAllUsers` (StorageExtension.js:30-45, 137-163), `getRemoteForumCollection`, and `getForumState` issue `getDocs(collection(...))` with no limit/pagination on every load and cache the result to AsyncStorage. Combined with #1 this dumps the full `users` collection (with passwords) to any client. It also allows an attacker to inflate collections to drive read costs and slow every client. +**Fix:** Enforce read restrictions in rules (#1), paginate/limit reads, and stop pulling the full `users` collection to the client at all once auth is reworked. +**Status:** Mitigatable. + +### 8 — Medium — Input validation gaps +`NewDiscussionView.js` gates submit only on non-empty trimmed `title`/`content` and a profanity check; there are no maximum-length bounds on title, content, tags, or profile `bio`, so oversized documents can be written (cost/DoS, and large base64 images). `videoURL` (Models / discussion creation) is not scheme-validated, so a `javascript:`/`data:` URL could be stored and later rendered. Moderation (`ContentModeration.moderateText`) only masks profanity in a copy — it does not block submission, and it is client-side so it is bypassable. +**Fix (now):** Add server-side-shaped length caps in rules plus client validation; validate `videoURL` against an `https?:` allowlist; treat moderation as advisory, not a control. +**Status:** Fixable now. + +### 9 — Medium — Dependency CVEs (`npm audit`) +Run in `--package-lock-only` mode (no installed tree). Totals: **34** advisories — **1 critical, 14 high, 18 moderate, 1 low**. Notable: +- **critical** `protobufjs` — arbitrary code execution / code injection through byte length. +- **high** `lodash` — code injection via `_.template`; **`tar`** — arbitrary file create/overwrite via hardlink traversal; **`@xmldom/xmldom`** — XML injection; the `@expo/cli`/`@expo/config*`/`expo`/`expo-asset`/`cacache` build chain. +- **moderate** `markdown-it` (uncontrolled resource consumption) and `react-native-markdown-display` — these reach runtime and compound Finding #6; also `postcss` (XSS via unescaped ``), `fast-xml-parser`, `ws`, `uuid`, `react-native`. +- **low** `send` — template-injection XSS. +Most high/critical entries are in build/CLI tooling, but `lodash`, `markdown-it`/`react-native-markdown-display`, and `uuid` are runtime-reachable. +**Fix (now):** `npm audit fix` where non-breaking; manually bump `react-native-markdown-display`/`markdown-it`, `lodash`, and `uuid`; review the Expo SDK bump for the CLI-chain advisories. +**Status:** Fixable now. + +### 10 — Low — Plaintext credentials cached in AsyncStorage +`StorageExtension` writes full user objects (incl. plaintext `password`) to AsyncStorage (`USERS_KEY`) on every load/sync. AsyncStorage is unencrypted; any device/app with storage access can read all cached credentials. Resolved largely by fixing #2 (never persist plaintext) and not caching the full `users` set. +**Status:** Mitigatable. + +--- + +## What's addressable under "harden in place, no Auth migration" + +**Fixable now (do these):** +- **#1** Write & deploy `firestore.rules` (+ `firebase.json` block) — highest leverage. +- **#6** Sanitize markdown link/image hrefs (`onLinkPress` + render rules) in detail view and preview. +- **#8** Add length caps + `videoURL` scheme validation. +- **#9** Upgrade vulnerable dependencies (`npm audit fix`, bump runtime-reachable libs). + +**Mitigatable in place (reduce risk, not a complete fix):** +- **#2** Hash passwords client-side; stop persisting plaintext (true fix needs server). +- **#3** Reserve admin usernames; recognize client-side admin check is not enforceable. +- **#4** Constrain destructive writes via rules; rework delete-by-diff sync. +- **#7** Restrict reads via rules; paginate; stop pulling full `users` to client. +- **#10** Don't persist plaintext credentials to AsyncStorage. + +**Deferred — genuinely needs server-side auth:** +- **#5** Authoritative authorization (admin/ban/mute/moderation). Firestore rules give partial mitigation, but real enforcement of "who is an admin / who may ban whom" requires server-verified identity, which is out of scope for this engagement. diff --git a/StorageExtension.js b/StorageExtension.js index 8584e70..d0de310 100644 --- a/StorageExtension.js +++ b/StorageExtension.js @@ -1,8 +1,93 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { collection, doc, getDoc, getDocs, setDoc, deleteDoc } from 'firebase/firestore'; +import * as Crypto from 'expo-crypto'; +import uuid from 'react-native-uuid'; import { getFirestoreDb, isFirebaseConfigured } from './FirebaseClient'; +// --- Password hashing -------------------------------------------------------- +// Passwords are hashed client-side with a per-user random salt before they are +// ever persisted (Firestore or AsyncStorage), so a leak of the world-readable +// `users` collection no longer discloses reusable plaintext credentials. +// +// We prefer PBKDF2-HMAC-SHA256 (a slow, salted KDF) via Web Crypto, which is +// available on the web deployment target (react-native-web / Firebase Hosting) +// and Node. A high iteration count makes offline brute force of any leaked +// hash far more expensive than a single SHA-256 round. Where Web Crypto is +// unavailable (some bare native runtimes), we fall back to a single SHA-256 +// round via expo-crypto; that fallback is weak against offline brute force and +// is tracked as a deferred follow-up. +// +// Hashes are self-describing — "pbkdf2$$" or "sha256$" — +// so verifyPassword() always re-derives with the same algorithm and parameters +// the stored hash was created with (handles mixed runtimes + the fallback). +// +// This remains a mitigation, NOT real authentication: verifying a password +// against a world-readable document is fundamentally client-side. Real auth +// (server-verified identity + rate limiting) is DEFERRED — there is no server +// identity in this app. +export const generateSalt = () => String(uuid.v4()); + +const PBKDF2_ITERATIONS = 150000; + +const getSubtle = () => + typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle + ? globalThis.crypto.subtle + : null; + +const toHex = (buffer) => + Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + +const pbkdf2Hex = async (subtle, password, salt, iterations) => { + const enc = new TextEncoder(); + const keyMaterial = await subtle.importKey( + 'raw', + enc.encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ); + const bits = await subtle.deriveBits( + { name: 'PBKDF2', salt: enc.encode(salt), iterations, hash: 'SHA-256' }, + keyMaterial, + 256 + ); + return toHex(bits); +}; + +const sha256Hex = (password, salt) => + Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, `${salt}${password}`); + +export const hashPassword = async (password, salt) => { + const subtle = getSubtle(); + if (subtle) { + const hex = await pbkdf2Hex(subtle, password, salt, PBKDF2_ITERATIONS); + return `pbkdf2$${PBKDF2_ITERATIONS}$${hex}`; + } + const hex = await sha256Hex(password, salt); + return `sha256$${hex}`; +}; + +// Re-derive using the algorithm + parameters encoded in the stored hash. +export const verifyPassword = async (password, salt, storedHash) => { + if (typeof storedHash !== 'string' || !storedHash) return false; + const parts = storedHash.split('$'); + + if (parts[0] === 'pbkdf2' && parts.length === 3) { + const subtle = getSubtle(); + if (!subtle) return false; // cannot verify a PBKDF2 hash without Web Crypto + const iterations = parseInt(parts[1], 10) || PBKDF2_ITERATIONS; + return (await pbkdf2Hex(subtle, password, salt, iterations)) === parts[2]; + } + if (parts[0] === 'sha256' && parts.length === 2) { + return (await sha256Hex(password, salt)) === parts[1]; + } + // Untagged legacy hash (raw SHA-256 hex): verify against that format. + return (await sha256Hex(password, salt)) === storedHash; +}; + const USERS_KEY = 'geekcollab_users'; const FORUM_STATE_KEY = 'geekcollab_forum_state'; const AUTH_SESSION_KEY = 'geekcollab_auth_session_user_id'; @@ -44,19 +129,46 @@ const getRemoteUsers = async (firestoreDb) => { return Array.isArray(users) ? users : []; }; +// Strip any plaintext `password` before a record is persisted. New records +// only carry passwordHash/passwordSalt; this defends against legacy objects or +// spread-in updates re-writing plaintext to Firestore/AsyncStorage. +const stripPlaintextPassword = (user) => { + if (!user || typeof user !== 'object') return user; + if (!('password' in user)) return user; + const { password, ...rest } = user; + return rest; +}; + +const secureUserForPersistence = async (user) => { + if (!user || typeof user !== 'object') return user; + if (typeof user.password === 'string' && (!user.passwordHash || !user.passwordSalt)) { + const passwordSalt = generateSalt(); + const passwordHash = await hashPassword(user.password, passwordSalt); + return { + ...stripPlaintextPassword(user), + passwordHash, + passwordSalt, + }; + } + return stripPlaintextPassword(user); +}; + +const secureUsersForPersistence = async (users) => + Promise.all((Array.isArray(users) ? users : []).map(secureUserForPersistence)); + +// ADDITIVE/TARGETED sync: write or update each provided user doc, but do NOT +// delete remote docs that merely aren't in the local list. This removes the +// one-call "delete-by-diff" full-collection wipe primitive. Explicit deletions +// go through deleteUserRemote(). +// Residual risk: a client can still overwrite an individual existing user doc +// (e.g. clobber another user's record) — there is no server identity to scope +// writes to the caller's own doc. That is DEFERRED to server auth. const saveRemoteUsers = async (firestoreDb, users) => { const usersCollection = collection(firestoreDb, FIREBASE_USERS_COLLECTION); - const existingUsersSnapshot = await getDocs(usersCollection); - const currentIds = new Set(users.map((user) => String(user.id))); - - await Promise.all( - existingUsersSnapshot.docs - .filter((snapshot) => !currentIds.has(snapshot.id)) - .map((snapshot) => deleteDoc(snapshot.ref)) - ); + const safeUsers = await secureUsersForPersistence(users); await Promise.all( - users.map((user) => setDoc(doc(usersCollection, String(user.id)), { + safeUsers.map((user) => setDoc(doc(usersCollection, String(user.id)), { ...user, id: String(user.id), updatedAt: new Date().toISOString(), @@ -77,16 +189,16 @@ const getRemoteForumCollection = async (firestoreDb, collectionName) => { return snapshot.docs.map((docSnap) => ({ id: docSnap.id, ...docSnap.data() })); }; +// ADDITIVE/TARGETED sync: write or update each provided doc, but never +// delete-by-diff across the whole collection. Removing the old "delete every +// remote doc not in the local list" behavior eliminates a one-call wipe +// primitive (a stale/malicious client could otherwise drop every other forum +// or discussion). Explicit removals go through deleteForumRemote / +// deleteDiscussionRemote, called from the discrete delete handlers. +// Residual risk: individual existing docs can still be overwritten by any +// client (no server identity to scope writes); DEFERRED to server auth. const syncRemoteForumCollection = async (firestoreDb, collectionName, items) => { const forumCollection = collection(firestoreDb, collectionName); - const existingSnapshot = await getDocs(forumCollection); - const currentIds = new Set(items.map((item) => String(item.id))); - - await Promise.all( - existingSnapshot.docs - .filter((snapshot) => !currentIds.has(snapshot.id)) - .map((snapshot) => deleteDoc(snapshot.ref)) - ); await Promise.all( items.map((item) => setDoc(doc(forumCollection, String(item.id)), { @@ -97,6 +209,26 @@ const syncRemoteForumCollection = async (firestoreDb, collectionName, items) => ); }; +// Targeted single-doc remote deletes. These replace the destructive +// delete-by-diff path for explicit user-initiated deletions, so removing one +// item touches only that one Firestore document. +const deleteRemoteDoc = async (collectionName, id) => { + if (!id) return; + const firestoreDb = getFirestoreDb(); + if (!firestoreDb) return; + try { + await deleteDoc(doc(firestoreDb, collectionName, String(id))); + } catch { + // local state removal still proceeds even if remote delete fails + } +}; + +export const deleteDiscussionRemote = async (discussionID) => + deleteRemoteDoc(FIREBASE_DISCUSSIONS_COLLECTION, discussionID); + +export const deleteForumRemote = async (forumID) => + deleteRemoteDoc(FIREBASE_FORUMS_COLLECTION, forumID); + const getLegacyForumState = async (firestoreDb) => { const forumStateRef = doc( firestoreDb, @@ -116,8 +248,9 @@ const setUsersSyncStatus = ({ source, error = null }) => { }; export const saveUser = async (user) => { + const safeUser = await secureUserForPersistence(user); const users = await getAllUsers(); - users.push(user); + users.push(safeUser); await AsyncStorage.setItem(USERS_KEY, JSON.stringify(users)); const firestoreDb = getFirestoreDb(); @@ -136,16 +269,23 @@ export const saveUser = async (user) => { export const getAllUsers = async () => { const data = await AsyncStorage.getItem(USERS_KEY); - const localUsers = parseStoredUsers(data); + const localUsers = await secureUsersForPersistence(parseStoredUsers(data)); + if (JSON.stringify(localUsers) !== (data || '[]')) { + await AsyncStorage.setItem(USERS_KEY, JSON.stringify(localUsers)); + } const firestoreDb = getFirestoreDb(); if (firestoreDb) { try { const remoteUsers = await getRemoteUsers(firestoreDb); + const safeRemoteUsers = await secureUsersForPersistence(remoteUsers); setUsersSyncStatus({ source: 'remote', error: null }); - if (remoteUsers.length > 0) { - await AsyncStorage.setItem(USERS_KEY, JSON.stringify(remoteUsers)); - return remoteUsers; + if (safeRemoteUsers.length > 0) { + await AsyncStorage.setItem(USERS_KEY, JSON.stringify(safeRemoteUsers)); + if (JSON.stringify(safeRemoteUsers) !== JSON.stringify(remoteUsers)) { + await saveRemoteUsers(firestoreDb, safeRemoteUsers); + } + return safeRemoteUsers; } if (localUsers.length > 0) { await saveRemoteUsers(firestoreDb, localUsers); @@ -186,45 +326,78 @@ export const isUsernameAvailable = async (username, excludeUserID = null) => { }); }; +// Persist a single user's record (additive/targeted — no collection wipe). +const persistUserRecord = async (user) => { + const firestoreDb = getFirestoreDb(); + if (!firestoreDb) return; + try { + const safeUser = await secureUserForPersistence(user); + const usersCollection = collection(firestoreDb, FIREBASE_USERS_COLLECTION); + await setDoc(doc(usersCollection, String(safeUser.id)), { + ...safeUser, + id: String(safeUser.id), + updatedAt: new Date().toISOString(), + }); + } catch { + // best-effort; local cache already holds the value + } +}; + export const getUser = async (username, password) => { const users = await getAllUsers(); - return users.find( - (u) => u.username.toLowerCase() === username.toLowerCase() && u.password === password - ) || null; + const candidate = users.find( + (u) => u.username && u.username.toLowerCase() === username.toLowerCase() + ); + if (!candidate) return null; + + // Preferred path: salted-hash comparison (format-aware: PBKDF2 or SHA-256). + if (candidate.passwordHash && candidate.passwordSalt) { + const ok = await verifyPassword(password, candidate.passwordSalt, candidate.passwordHash); + return ok ? candidate : null; + } + + // Legacy path: record still holds a plaintext `password`. Compare once, and + // on success transparently migrate it to a salted hash so plaintext is never + // written again. + if (typeof candidate.password === 'string') { + if (candidate.password !== password) return null; + const salt = generateSalt(); + const passwordHash = await hashPassword(password, salt); + const migrated = { ...candidate, passwordHash, passwordSalt: salt }; + delete migrated.password; + + const users2 = users.map((u) => (u.id === migrated.id ? migrated : u)); + await AsyncStorage.setItem(USERS_KEY, JSON.stringify(users2)); + await persistUserRecord(migrated); + return migrated; + } + + return null; }; export const updateUserBanStatus = async (userID, isBanned) => { const users = await getAllUsers(); - const nextUsers = users.map((user) => - user.id === userID ? { ...user, isBanned } : user - ); + let changed = null; + const nextUsers = users.map((user) => { + if (user.id !== userID) return user; + changed = { ...user, isBanned }; + return changed; + }); await AsyncStorage.setItem(USERS_KEY, JSON.stringify(nextUsers)); - - const firestoreDb = getFirestoreDb(); - if (firestoreDb) { - try { - await saveRemoteUsers(firestoreDb, nextUsers); - } catch { - // keep local update even if remote fails - } - } + // Targeted write of only the affected user doc (no full-collection rewrite). + if (changed) await persistUserRecord(changed); }; export const updateUserMuteStatus = async (userID, mutedUntil) => { const users = await getAllUsers(); - const nextUsers = users.map((user) => - user.id === userID ? { ...user, mutedUntil: mutedUntil || null } : user - ); + let changed = null; + const nextUsers = users.map((user) => { + if (user.id !== userID) return user; + changed = { ...user, mutedUntil: mutedUntil || null }; + return changed; + }); await AsyncStorage.setItem(USERS_KEY, JSON.stringify(nextUsers)); - - const firestoreDb = getFirestoreDb(); - if (firestoreDb) { - try { - await saveRemoteUsers(firestoreDb, nextUsers); - } catch { - // keep local update even if remote fails - } - } + if (changed) await persistUserRecord(changed); }; export const updateUserRole = async (userID, role, options = {}) => { @@ -234,68 +407,64 @@ export const updateUserRole = async (userID, role, options = {}) => { const { addForumModerator, removeForumModerator } = options; const users = await getAllUsers(); - let didUpdate = false; + let changed = null; const nextUsers = users.map((user) => { if (user.id !== userID) return user; - didUpdate = true; - + let forumModerators = Array.isArray(user.forumModerators) ? [...user.forumModerators] : []; - + if (addForumModerator) { if (!forumModerators.includes(addForumModerator)) { forumModerators.push(addForumModerator); } } - + if (removeForumModerator) { forumModerators = forumModerators.filter((fid) => fid !== removeForumModerator); } - - return { ...user, role: normalizedRole, forumModerators }; + + changed = { ...user, role: normalizedRole, forumModerators }; + return changed; }); - if (!didUpdate) return false; + if (!changed) return false; await AsyncStorage.setItem(USERS_KEY, JSON.stringify(nextUsers)); - const firestoreDb = getFirestoreDb(); - if (firestoreDb) { - try { - await saveRemoteUsers(firestoreDb, nextUsers); - } catch { - // keep local role update even if remote fails - } - } + // Targeted write of only the affected user doc (no full-collection rewrite). + await persistUserRecord(changed); return true; }; export const updateUserProfile = async (userID, updates = {}) => { if (!userID) return null; + // Never let a profile update (re)introduce a plaintext password field, and + // cap free-text bio length to bound document size. + const { password: _ignoredPassword, ...safeUpdates } = updates || {}; + const MAX_BIO_LEN = 500; + const users = await getAllUsers(); let updatedUser = null; const nextUsers = users.map((user) => { if (user.id !== userID) return user; - const nextUsername = updates.username !== undefined ? String(updates.username).trim() : user.username; + const nextUsername = safeUpdates.username !== undefined ? String(safeUpdates.username).trim() : user.username; + const nextBio = safeUpdates.bio !== undefined + ? String(safeUpdates.bio).trim().slice(0, MAX_BIO_LEN) + : user.bio; updatedUser = { - ...user, - ...updates, + ...stripPlaintextPassword(user), + ...safeUpdates, username: nextUsername, - displayName: updates.displayName !== undefined ? String(updates.displayName).trim() : user.displayName, - bio: updates.bio !== undefined ? String(updates.bio).trim() : user.bio, - profileImage: updates.profileImage !== undefined ? updates.profileImage : user.profileImage, + displayName: safeUpdates.displayName !== undefined ? String(safeUpdates.displayName).trim() : user.displayName, + bio: nextBio, + profileImage: safeUpdates.profileImage !== undefined ? safeUpdates.profileImage : user.profileImage, }; return updatedUser; }); if (!updatedUser) return null; await AsyncStorage.setItem(USERS_KEY, JSON.stringify(nextUsers)); - const firestoreDb = getFirestoreDb(); - if (firestoreDb) { - try { - await saveRemoteUsers(firestoreDb, nextUsers); - } catch { - // keep local update even if remote fails - } - } + // Targeted write of only the affected user doc (no full-collection rewrite). + await persistUserRecord(updatedUser); return updatedUser; }; diff --git a/firebase.json b/firebase.json index d6e4844..aa5a311 100644 --- a/firebase.json +++ b/firebase.json @@ -1,4 +1,10 @@ { + "firestore": { + "rules": "firestore.rules" + }, + "storage": { + "rules": "storage.rules" + }, "hosting": { "public": "dist", "ignore": [ diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..bcbfad8 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,155 @@ +rules_version = '2'; + +// ============================================================================= +// Geek-Collab Firestore security rules +// +// HONEST SCOPE: These rules provide SCHEMA VALIDATION and ABUSE-RESISTANCE +// only. The app is an anonymous client with NO server identity (no Firebase +// Auth), so these rules CANNOT enforce true per-user authorization: +// - "a user may only edit their own document" +// - "only a real admin may ban/mute/change roles" +// - authoritative admin / moderation / ban enforcement +// All of that is DEFERRED until a server identity (Firebase Auth or a trusted +// backend) is introduced. Until then privilege fields like `role`, `isBanned` +// and `mutedUntil` are writable by any client by design — the app drives them +// from client-side moderation code. We therefore VALIDATE their type/shape +// (so they can't be abused to inject arbitrary data) rather than pretending to +// authorize them. +// +// What these rules DO buy us: +// - default-deny for any collection/path not explicitly listed +// - type + enum + length caps on every writable field (anti-DoS / anti-junk) +// - reject documents missing required keys or carrying unknown junk fields +// - bounded document size to limit cost-amplification / storage abuse +// ============================================================================= + +service cloud.firestore { + match /databases/{database}/documents { + + // ---- shared validation helpers ----------------------------------------- + function isStr(v, maxLen) { + return v is string && v.size() <= maxLen; + } + function optStr(data, field, maxLen) { + return !(field in data) || data[field] == null || isStr(data[field], maxLen); + } + function isBoolOrAbsent(data, field) { + return !(field in data) || data[field] is bool; + } + // http/https-only URL (or empty/null). Blocks javascript:/data: schemes. + function isWebUrlOrEmpty(v) { + return v == null || v == '' || + (v is string && v.size() <= 2048 && + (v.matches('https://.*') || v.matches('http://.*'))); + } + + // ===== users ============================================================ + // Readable so the anonymous client can run its (deferred) login compare. + // NOTE: passwords are stored hashed+salted by the client (see AuthViewModel) + // so a read no longer discloses reusable plaintext credentials. True + // confidentiality of this collection still requires server auth (deferred). + match /users/{userId} { + allow read: if true; + + function validUser(d) { + return d.keys().hasOnly([ + 'id', + 'username', + 'displayName', + 'passwordHash', + 'passwordSalt', + 'role', + 'bio', + 'profileImage', + 'isBanned', + 'mutedUntil', + 'forumModerators', + 'createdAt', + 'updatedAt' + ]) + && d.id is string && d.id.size() <= 128 + && isStr(d.username, 64) + && optStr(d, 'displayName', 64) + // legacy plaintext `password` is no longer written; only hash+salt. + && optStr(d, 'passwordHash', 256) + && optStr(d, 'passwordSalt', 256) + && (!('role' in d) || d.role in ['admin', 'user']) + && optStr(d, 'bio', 500) + && isBoolOrAbsent(d, 'isBanned') + && optStr(d, 'mutedUntil', 64) + && (!('forumModerators' in d) || d.forumModerators is list) + // profileImage is base64; cap to bound document size. + && optStr(d, 'profileImage', 2000000); + } + + allow create, update: if validUser(request.resource.data); + // Deletion is permitted (no server identity to scope it) but the + // delete-by-diff bulk wipe primitive has been removed from the client. + allow delete: if true; + } + + // ===== forums =========================================================== + match /forums/{forumId} { + allow read: if true; + + function validForum(d) { + return d.id is string && d.id.size() <= 128 + && isStr(d.title, 200) + && optStr(d, 'createdByID', 128) + && optStr(d, 'createdByName', 64) + && isBoolOrAbsent(d, 'isReadOnly') + && optStr(d, 'createdAt', 64) + && optStr(d, 'expiresAt', 64); + } + + allow create, update: if validForum(request.resource.data); + allow delete: if true; + } + + // ===== discussions ====================================================== + match /discussions/{discussionId} { + allow read: if true; + + function validDiscussion(d) { + return d.id is string && d.id.size() <= 128 + && isStr(d.title, 200) + && optStr(d, 'description', 1000) + && optStr(d, 'content', 20000) + && optStr(d, 'authorID', 128) + && optStr(d, 'authorName', 64) + && optStr(d, 'forumID', 128) + && (!('tags' in d) || d.tags is list) + && (!('comments' in d) || d.comments is list) + && (!('reports' in d) || d.reports is list) + && (!('likesBy' in d) || d.likesBy is list) + && (!('likes' in d) || d.likes is number) + && isWebUrlOrEmpty(d.get('videoURL', null)); + } + + allow create, update: if validDiscussion(request.resource.data); + allow delete: if true; + } + + // ===== forumMeta (singleton aggregate state doc) ======================== + match /forumMeta/{docId} { + allow read: if true; + // Bounded shape: all keys optional maps/lists; reject non-object writes. + allow write: if request.resource.data is map; + } + + // ===== legacy appState ================================================== + // Deprecated single-doc layout. The client migrates these into the + // collections above then deletes them. Allow read + delete so migration + // can drain old data, but block creation of new legacy docs. + match /appState/{docId} { + allow read, delete: if true; + allow create, update: if false; + } + + // ===== default deny ===================================================== + // Any collection/document not matched above is fully denied. + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/package-lock.json b/package-lock.json index 56867a6..6dc6ace 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/datetimepicker": "8.0.1", "expo": "~51.0.0", + "expo-crypto": "~13.0.2", "expo-status-bar": "~1.12.1", "firebase": "^12.11.0", "leo-profanity": "^1.9.0", @@ -31,12 +32,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -84,13 +85,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -194,9 +195,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -216,27 +217,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -258,9 +259,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -314,18 +315,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -454,12 +455,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1387,16 +1388,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2145,31 +2146,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -2177,13 +2178,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -3130,6 +3131,20 @@ } } }, + "node_modules/@firebase/auth-compat/node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@firebase/auth-interop-types": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", @@ -4355,25 +4370,24 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -4383,9 +4397,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -4401,9 +4415,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@react-native-async-storage/async-storage": { @@ -6701,9 +6715,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6855,9 +6869,9 @@ } }, "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -8195,6 +8209,18 @@ "expo": "*" } }, + "node_modules/expo-crypto": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-13.0.2.tgz", + "integrity": "sha512-7f/IMPYJZkBM21LNEMXGrNo/0uXSVfZTwufUdpNKedJR0fm5fH4DCSN79ZddlV26nF90PuXjK2inIbI6lb0qRA==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-17.0.1.tgz", @@ -8323,9 +8349,9 @@ "license": "MIT" }, "node_modules/fast-xml-parser": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.5.tgz", - "integrity": "sha512-cK9c5I/DwIOI7/Q7AlGN3DuTdwN61gwSfL8rvuVPK+0mcCNHHGxRrpiFtaZZRfRMJL3Gl8B2AFlBG6qXf03w9A==", + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz", + "integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==", "funding": [ { "type": "github", @@ -8550,6 +8576,20 @@ } } }, + "node_modules/firebase/node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/flow-enums-runtime": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", @@ -10760,9 +10800,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -12562,9 +12602,9 @@ } }, "node_modules/plist/node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -12743,24 +12783,24 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz", + "integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -15532,9 +15572,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 645f335..8b7df8d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/datetimepicker": "8.0.1", "expo": "~51.0.0", + "expo-crypto": "~13.0.2", "expo-status-bar": "~1.12.1", "firebase": "^12.11.0", "leo-profanity": "^1.9.0", diff --git a/storage.rules b/storage.rules new file mode 100644 index 0000000..c846217 --- /dev/null +++ b/storage.rules @@ -0,0 +1,23 @@ +rules_version = '2'; + +// ============================================================================= +// Geek-Collab Cloud Storage security rules +// +// This app stores user/post images as base64 fields inside Firestore +// documents — it does NOT currently use Cloud Storage for uploads. With no +// server identity (no Firebase Auth) there is no safe way to permit anonymous +// writes to the storage bucket without handing out an unbounded file-upload / +// cost-amplification primitive to the whole internet. +// +// Therefore: DEFAULT-DENY the entire bucket. If a real upload feature is added +// later it should go through a server identity (Firebase Auth) so per-user +// authorization and size/type limits can be enforced. That is DEFERRED. +// ============================================================================= + +service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } +} diff --git a/urlSafety.js b/urlSafety.js new file mode 100644 index 0000000..d3f95b1 --- /dev/null +++ b/urlSafety.js @@ -0,0 +1,55 @@ +// Lightweight, dependency-free URL scheme allowlisting used to harden the +// markdown renderer against javascript:/data:/vbscript:/file: schemes on web. + +const SAFE_SCHEMES = ['http', 'https', 'mailto']; + +// Decode numeric (j) and hex (j) HTML entities so that entity-encoded +// scheme tricks (e.g. "javascript:") are normalized before we check. +const decodeEntities = (input) => + input.replace(/&#(x?)([0-9a-fA-F]+);?/g, (match, isHex, code) => { + const point = parseInt(code, isHex ? 16 : 10); + if (Number.isNaN(point)) return match; + try { + return String.fromCodePoint(point); + } catch { + return match; + } + }); + +// Strip whitespace and control characters that browsers ignore inside schemes +// (e.g. "java\tscript:" or leading/trailing nulls). +const normalize = (input) => + decodeEntities(String(input)) + .replace(/[\u0000-\u0020\u007F-\u009F]/g, '') + .trim(); + +export const isSafeUrl = (url) => { + if (url === null || url === undefined) return false; + + const normalized = normalize(url).toLowerCase(); + if (!normalized) return false; + + // Match an explicit URL scheme, e.g. "https:", "javascript:". + const schemeMatch = normalized.match(/^([a-z][a-z0-9+.-]*):/); + + // No explicit scheme means a relative/anchor/fragment link (e.g. "#x", + // "/foo", "page.html"). These cannot carry a dangerous scheme, so allow them. + if (!schemeMatch) return true; + + return SAFE_SCHEMES.includes(schemeMatch[1]); +}; + +// Stricter check for resources that are actually fetched/loaded (e.g. images): +// only http(s) is allowed. mailto/relative/anchor are not valid image sources. +export const isHttpUrl = (url) => { + if (url === null || url === undefined) return false; + const normalized = normalize(url).toLowerCase(); + const schemeMatch = normalized.match(/^([a-z][a-z0-9+.-]*):/); + if (!schemeMatch) return false; + return schemeMatch[1] === 'http' || schemeMatch[1] === 'https'; +}; + +// Returns the original url if it is safe to open, otherwise null. +export const safeUrl = (url) => (isSafeUrl(url) ? url : null); + +export default isSafeUrl;