diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index a526b717..89736e76 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -393,7 +393,44 @@ function generateSwaggerPaths(api: typeof API) { return paths } -const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? 'API_KEY' +// const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? 'API_KEY' + +// const accessInfo = ` +// To obtain a token: +// +// **In your browser console while logged in (CTRL+SHIFT+C, then select the Console tab):** +// \`\`\`js +// const db = await new Promise((res, rej) => { +// const req = indexedDB.open('firebaseLocalStorageDb') +// req.onsuccess = () => res(req.result) +// req.onerror = rej +// }) +// const req = db.transaction('firebaseLocalStorage', 'readonly').objectStore('firebaseLocalStorage').getAll() +// req.onsuccess = () => { +// const id_token = req.result[0].value.stsTokenManager.accessToken +// console.log('YOUR_FIREBASE_JWT_TOKEN is the string below:') +// console.log(id_token) +// copy(id_token) +// } +// \`\`\` +// +// **For testing (REST):** +// \`\`\`bash +// curl 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${apiKey}' \\ +// -H 'Content-Type: application/json' \\ +// --data '{"email":"you@example.com","password":"yourpassword","returnSecureToken":true}' +// # Use the returned idToken as your Bearer token +// \`\`\` +// +// Tokens expire after **1 hour**. Refresh by calling \`getIdToken(true)\`. +// +// Pass the token in the Authorization header for all authenticated requests: +// \`\`\` +// Authorization: Bearer YOUR_FIREBASE_JWT_TOKEN +// \`\`\` +// +// In the API docs, authenticate through the green button at the bottom right of this section (BearerAuth token). +// ` const swaggerDocument: OpenAPIV3.Document = { openapi: '3.0.0', @@ -410,41 +447,6 @@ Some endpoints are publicly accessible without authentication, such as events an ### Tier 2 — User Access (Firebase authentication required) Most endpoints require a valid Firebase JWT token. This gives you access to your own user data, profile, messages, and all interactive features. - To obtain a token: - - **In your browser console while logged in (CTRL+SHIFT+C, then select the Console tab):** -\`\`\`js -const db = await new Promise((res, rej) => { - const req = indexedDB.open('firebaseLocalStorageDb') - req.onsuccess = () => res(req.result) - req.onerror = rej -}) -const req = db.transaction('firebaseLocalStorage', 'readonly').objectStore('firebaseLocalStorage').getAll() -req.onsuccess = () => { - const id_token = req.result[0].value.stsTokenManager.accessToken - console.log('YOUR_FIREBASE_JWT_TOKEN is the string below:') - console.log(id_token) - copy(id_token) -} -\`\`\` - -**For testing (REST):** -\`\`\`bash -curl 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${apiKey}' \\ - -H 'Content-Type: application/json' \\ - --data '{"email":"you@example.com","password":"yourpassword","returnSecureToken":true}' -# Use the returned idToken as your Bearer token -\`\`\` - -Tokens expire after **1 hour**. Refresh by calling \`getIdToken(true)\`. - -Pass the token in the Authorization header for all authenticated requests: -\`\`\` -Authorization: Bearer YOUR_FIREBASE_JWT_TOKEN -\`\`\` - -In the API docs, authenticate through the green button at the bottom right of this section (BearerAuth token). - **Don't have an account?** [Register on Compass](${DEPLOYED_WEB_URL}/register) to get started. ## Rate Limiting diff --git a/backend/api/src/get-private-messages.ts b/backend/api/src/get-private-messages.ts index b35517eb..ea9fbbed 100644 --- a/backend/api/src/get-private-messages.ts +++ b/backend/api/src/get-private-messages.ts @@ -29,7 +29,8 @@ export async function getChannelMessages(props: { and exists (select 1 from private_user_message_channel_members pumcm where pumcm.user_id = $2 - and pumcm.channel_id = $1) + and pumcm.channel_id = $1 + and pumcm.status != 'left') and ($4 is null or id > $4) and ($5 is null or id < $5) and not visibility = 'system_status' diff --git a/backend/api/src/helpers/private-messages.ts b/backend/api/src/helpers/private-messages.ts index 0d0a166e..af8f7de0 100644 --- a/backend/api/src/helpers/private-messages.ts +++ b/backend/api/src/helpers/private-messages.ts @@ -119,12 +119,14 @@ export const createPrivateUserMessageMain = async ( ) => { log('createPrivateUserMessageMain', creator, channelId, content) - // Normally, users can only submit messages to channels that they are members of + // Normally, users can only submit messages to channels that they are active members of + // (a member who has left keeps their row with status = 'left' and must not be able to post). const authorized = await pg.oneOrNone( `select 1 from private_user_message_channel_members where channel_id = $1 - and user_id = $2`, + and user_id = $2 + and status != 'left'`, [channelId, creator.id], ) if (!authorized) throw APIErrors.forbidden('You are not authorized to post to this channel') diff --git a/backend/api/src/react-to-message.ts b/backend/api/src/react-to-message.ts index 6d9ee918..44950ae3 100644 --- a/backend/api/src/react-to-message.ts +++ b/backend/api/src/react-to-message.ts @@ -15,7 +15,8 @@ export const reactToMessage: APIHandler<'react-to-message'> = async ( FROM private_user_message_channel_members m JOIN private_user_messages msg ON msg.channel_id = m.channel_id WHERE m.user_id = $1 - AND msg.id = $2`, + AND msg.id = $2 + AND m.status != 'left'`, [auth.uid, messageId], ) diff --git a/backend/api/src/update-notif-setting.ts b/backend/api/src/update-notif-setting.ts index 8b068e76..445a8ac3 100644 --- a/backend/api/src/update-notif-setting.ts +++ b/backend/api/src/update-notif-setting.ts @@ -14,12 +14,15 @@ export const updateNotifSettings: APIHandler<'update-notif-settings'> = async ( interestedInPushNotifications: !enabled, }) } else { - // deep update array at data.notificationPreferences[type] + // deep update array at data.notificationPreferences[type]. + // `type` and `medium` are passed as bound parameters (never interpolated into the SQL + // text) and are additionally constrained by the Zod enums on the endpoint schema. The + // jsonb path is built with array[...] so the key is a value, not raw SQL. await pg.none( `update private_users - set data = jsonb_set(data, '{notificationPreferences, $1:raw}', + set data = jsonb_set(data, array['notificationPreferences', $1], coalesce(data->'notificationPreferences'->$1, '[]'::jsonb) - ${enabled ? `|| '[$2:name]'::jsonb` : `- $2`} + ${enabled ? `|| to_jsonb($2::text)` : `- $2`} ) where id = $3`, [type, medium, auth.uid], diff --git a/backend/api/src/update-private-user-message-channel.ts b/backend/api/src/update-private-user-message-channel.ts index ace95ece..0ce1bff4 100644 --- a/backend/api/src/update-private-user-message-channel.ts +++ b/backend/api/src/update-private-user-message-channel.ts @@ -13,7 +13,7 @@ export const updatePrivateUserMessageChannel: APIHandler< const membershipStatus = await pg.oneOrNone( `select status from private_user_message_channel_members - where channel_id = $1 and user_id = $2`, + where channel_id = $1 and user_id = $2 and status != 'left'`, [channelId, auth.uid], ) if (!membershipStatus) throw APIErrors.forbidden('You are not authorized to this channel') diff --git a/backend/shared/src/supabase/utils.ts b/backend/shared/src/supabase/utils.ts index c748b15d..78668052 100644 --- a/backend/shared/src/supabase/utils.ts +++ b/backend/shared/src/supabase/utils.ts @@ -88,7 +88,10 @@ export async function update( updates: (Partial> & {id: string})[], ) { if (updates.length > 0) { + // Each tuple is fully escaped by pg-promise ($(id) as a value, $(update:json) serialized + // and quoted) before being joined. The previous version interpolated `id` raw and used + // `.replace("'", "''")`, which only escapes the FIRST quote — both a correctness bug and + // an injection vector if any id/value contained a quote. const values = updates - .map(({id, ...update}) => `('${id}', '${JSON.stringify(update).replace("'", "''")}'::jsonb)`) + .map(({id, ...update}) => pgp.as.format(`($(id), $(update:json)::jsonb)`, {id, update})) .join(',\n') await db.none( - `update ${table} as c + pgp.as.format( + `update $(table:name) as c set data = data || v.update - from (values ${values}) as v(id, update) + from (values $(values:raw)) as v(id, update) where c.id = v.id`, + {table, values}, + ), ) } } @@ -185,12 +195,17 @@ export async function updateData( } const sortedExtraOperations = sortBy(extras, (statement) => (statement.startsWith('-') ? -1 : 1)) + // `id` is bound as a value ($(id)) and `table`/`idField` as escaped identifiers (:name) + // rather than interpolated into the SQL text — otherwise an attacker-controlled id (e.g. a + // path param routed here via updatePrivateUser/updateUser) could break out of the + // `where ... = '...'` literal and run arbitrary SQL. The extra operations are already + // escaped by the FieldVal helpers that produced them. return await db.one>( - `update ${table} set data = data + `update $(table:name) set data = data ${sortedExtraOperations.join('\n')} - || $1 - where ${idField} = '${id}' returning *`, - [JSON.stringify(basic)], + || $(basic:json) + where $(idField:name) = $(id) returning *`, + {table, idField, id, basic}, ) } @@ -216,9 +231,13 @@ export const FieldVal = { arrayRemove: (...values: string[]) => (fieldName: string) => { + // Build the text[] with an array[...] constructor whose elements are individually + // escaped by pg-promise (:list), rather than interpolating the joined values into the + // SQL with :raw, which does no escaping and let a value like `a'}'::text[]); drop ...` + // break out of the literal. return pgp.as.format( - `|| jsonb_build_object($1, coalesce(data->$1,'[]'::jsonb) - '{$2:raw}'::text[])`, - [fieldName, values.join(',')], + `|| jsonb_build_object($1, coalesce(data->$1,'[]'::jsonb) - array[$2:list]::text[])`, + [fieldName, values], ) }, } diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 71994fcf..521a7a87 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -15,7 +15,7 @@ import {RepoStats, Stats} from 'common/stats' // mqp: very unscientific, just ba import {PrivateMessageChannel} from 'common/supabase/private-messages' import {Row} from 'common/supabase/utils' import {PrivateUser, User} from 'common/user' -import {notification_preference} from 'common/user-notification-preferences' +import {NOTIFICATION_PREFERENCE_TYPES} from 'common/user-notification-preferences' import {arrify} from 'common/util/array' import {z} from 'zod' @@ -423,7 +423,7 @@ export const API = (_apiTypeCheck = { authed: true, rateLimited: false, props: z.object({ - type: z.string() as z.ZodType, + type: z.enum(NOTIFICATION_PREFERENCE_TYPES), medium: z.enum(['email', 'browser', 'mobile']), enabled: z.boolean(), }), diff --git a/common/src/user-notification-preferences.ts b/common/src/user-notification-preferences.ts index 3d2bf61b..87dea84a 100644 --- a/common/src/user-notification-preferences.ts +++ b/common/src/user-notification-preferences.ts @@ -6,6 +6,36 @@ import {filterDefined} from './util/array' export const NOTIFICATION_DESTINATION_TYPES = ['email', 'browser', 'mobile'] as const export type notification_destination_types = (typeof NOTIFICATION_DESTINATION_TYPES)[number] export type notification_preference = keyof notification_preferences + +// Runtime allow-list of valid preference keys. Kept in sync with `notification_preferences` +// by the `satisfies` clause below — adding a key to the type without adding it here (or vice +// versa) is a compile error. Used to validate the untrusted `type` field on the wire so it +// never reaches a SQL statement unchecked. +export const NOTIFICATION_PREFERENCE_TYPES = [ + 'new_match', + 'new_endorsement', + 'new_profile_like', + 'new_profile_ship', + 'new_search_alerts', + 'connection_interest_match', + 'new_message', + 'tagged_user', + 'on_new_follow', + 'onboarding_flow', + 'thank_you_for_purchases', + 'platform_updates', + 'opt_out_all', +] as const satisfies readonly notification_preference[] + +// Reverse check: every key of `notification_preferences` must appear in the list above. +// `satisfies` only guards the forward direction; this errors if a key is ever missing. +type _AllPreferenceTypesListed = + notification_preference extends (typeof NOTIFICATION_PREFERENCE_TYPES)[number] + ? true + : ['missing notification_preference in NOTIFICATION_PREFERENCE_TYPES'] +const _assertAllPreferenceTypesListed: _AllPreferenceTypesListed = true +void _assertAllPreferenceTypesListed + export type notification_preferences = { new_match: notification_destination_types[] new_endorsement: notification_destination_types[]