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;