diff --git a/SECURITY.md b/SECURITY.md index 06b764c..11dce15 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -150,6 +150,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | DATA-001A6 / registration snapshot-processing failure containment | DATA-001A6 [#566](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/566) separately catches without binding every exceptional exact-UID `QuerySnapshot.forEach` and owned-document reference, ID, data, or retained-field projection outcome. It returns only fixed `unavailable / Registration data could not be loaded.` before event-ID derivation or event access. Synthetic iterator, reference-path, projection-time `data()`, and retained-field failures prove no caught-value getter, coercion, serialization, formatting hook, console/Firebase logger, event read, or later-document projection is reached while the exact query and successful/empty behavior stay unchanged. | This source is not deployed or live. It does not contain exceptional event-ID derivation, event collection/document/get/snapshot/projection work, or later registration sorting; change stored data, field retrieval, or response shape; add retries; or prove Firebase logging outside application code. Complete a protected Function deployment/readback and made-up account check before describing the boundary as live; harden later event/sort stages separately. | | DATA-001A7 / registration event-acquisition failure containment | DATA-001A7 [#575](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/575) catches without binding every exceptional event collection, document, or `get` acquisition outcome and returns only fixed `unavailable / Registration data could not be loaded.` before a successful event snapshot or field projection for the failing ID. Synthetic stage failures prove no caught-value getter, coercion, serialization, formatting hook, console/Firebase logger, event-data read, or partial response is reached while App Check, Auth, the exact-UID registration query, registration projection, event lookup concurrency, successful/empty behavior, and created-time sort stay unchanged. | This source is not deployed or live. It does not contain exceptional event-ID derivation, event snapshot `exists`, `data()`, retained-field projection, concurrent sibling cancellation/order, or later registration sorting; change full Firestore document retrieval, stored data, response shape, or permissions; add retries; or prove platform logging outside application code. Complete a protected Function deployment/readback and made-up account check before describing the fixed boundary as live; harden later event projection and sort stages separately. | | DATA-001A8 / registration event snapshot-processing failure containment | DATA-001A8 [#579](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/579) separately catches without binding every exceptional acquired event snapshot `exists`, `data()`, or retained `slug`, `title`, `startAt`, and `location` projection outcome. It returns only fixed `unavailable / Registration data could not be loaded.` and no partial callable response. Synthetic snapshot and field failures prove no caught-value getter, coercion, serialization, formatting hook, console/Firebase logger, or later registration sort is reached while App Check, Auth, the exact-UID query, runner minimization, A7 acquisition, missing-event success, valid event projection, lookup concurrency, successful/empty behavior, and created-time sort stay unchanged. | This source is not deployed or live. It does not validate event IDs or malformed nonthrowing event values; cancel or serialize concurrent sibling reads; contain event-ID derivation, later registration sorting, response/platform serialization, or platform logging outside application code; change full Firestore document retrieval, stored data, response shape, permissions, or retries; or prove live behavior. Complete a protected Function deployment/readback and made-up account check before describing the fixed boundary as live; harden remaining derivation/sort/serialization residuals separately. | +| DATA-001A9 / registration-sort failure containment | DATA-001A9 [#598](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/598) catches without binding every exceptional created-time comparison outcome in the final registration sort after event work completes and returns only fixed `unavailable / Registration data could not be loaded.` with no partial callable response. Synthetic `_seconds` access and numeric-coercion failures prove no caught failure-value inspection, formatting, serialization, logging, or raw escape while App Check, Auth, the exact-UID query, runner minimization, A5–A8 boundaries, event lookup concurrency, successful and empty behavior, and descending registration-created ordering stay unchanged. | This source is not deployed or live. It does not validate malformed-but-nonthrowing timestamps or event IDs; alter fallback-to-zero or tie behavior; contain event-ID derivation or response/platform serialization; cancel or reorder already completed event reads; change stored data, response shape, permissions, or retries; prove platform logging outside application code; or prove live behavior. Complete a protected Function deployment/readback and made-up account check before describing the fixed boundary as live. | | RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | | RISK-039 | The #118 source slice uses an empty authenticated callable request, bounded Firebase Auth identity fields, one transactional create-only helper shared with signup, constant responses, generic failures, and a UI that hides Edit until setup and the Rules-protected read succeed. Signup and recovery never change custom claims; browser profile creation remains denied. | Source review/merge is not deployment. Under #105, deploy the exact #100 Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` before the website, prove App Check policy, use a synthetic staged account, verify rollback, then record website, Function, Rules, and live behavior separately. Never repair a real profile manually. | | RISK-040 | AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged as `46557c7`: account creation returns `accepted` or `unavailable` without exposing provider details. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) merged as `23bca8c8`: My Account makes no false “sent” claim, blocks rapid repeats, and applies the same 60-second browser cooldown after either outcome. Its protected release run `29252492614` stopped before build because the required public App Check key was absent, so neither frontend revision is published. AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one byte-equivalent password-reset request result after provider success or failure. AUTH-MAIL-002C2 [#194](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/194) tracks the source-only `/auth/action` verification path: the initial capability suppresses Sentry/App Check, the page removes native and router query/fragment state, a scanner-style page load makes no action-code check/apply and no account mutation, and one deliberate action requires provider `VERIFY_EMAIL` before apply. Fixed results expose no email, code, raw provider error, account identity, or provider-directed navigation. Synthetic tests use mocks and canary values only. | Publish and verify each exact frontend revision separately. The existing #99 Pages bridge briefly uses tab-local session storage for the return route and deletes it before React; a failed root load can leave it until tab close, while direct-rewrite hosting avoids that residual. #194 adds no storage write. #118 profile source is merged but live behavior is unproven. Keep delivery, Spam, DNS, templates, provider handler choice, and private Firebase email-enumeration-protection readback under #119. Firebase uses one custom handler for verification, password reset, and email recovery; never point its global action URL at the verification-only #194 route until every enabled mode is safely handled, or keep the default multi-mode handler. Accepted requests do not prove delivery, browser cooldowns are not abuse controls, Auth verification does not grant membership, and the Firestore verification mirror remains unfinished AUTH-001 work. | diff --git a/functions/listMyRegistrations.js b/functions/listMyRegistrations.js index 4b3957a..3e7b399 100644 --- a/functions/listMyRegistrations.js +++ b/functions/listMyRegistrations.js @@ -83,11 +83,19 @@ exports.listMyRegistrations = functions.https.onCall(async (data, context) => { } })); - const registrations = Array.from(results.values()).sort((a, b) => { - const ta = a.createdAt?._seconds || 0; - const tb = b.createdAt?._seconds || 0; - return tb - ta; - }); + let registrations; + try { + registrations = Array.from(results.values()).sort((a, b) => { + const ta = a.createdAt?._seconds || 0; + const tb = b.createdAt?._seconds || 0; + return tb - ta; + }); + } catch { + throw new functions.https.HttpsError( + 'unavailable', + 'Registration data could not be loaded.', + ); + } return { registrations, events }; }); diff --git a/functions/listMyRegistrations.test.js b/functions/listMyRegistrations.test.js index 7e0a7d1..22392ba 100644 --- a/functions/listMyRegistrations.test.js +++ b/functions/listMyRegistrations.test.js @@ -1143,3 +1143,216 @@ describe('DATA-001A8 registration event snapshot/projection failure boundary', ( expect(admin.__eventSnapshotStages()).toEqual(['exists:synthetic-event']); }); }); + +describe('DATA-001A9 registration-sort failure boundary', () => { + beforeEach(() => { + admin.__reset(); + admin.firestore.mockClear(); + firebaseFunctions.__resetLoggerCalls(); + requireAppCheck.mockReset(); + }); + + test.each([ + ['nested seconds access', 1, 0], + ['numeric coercion', 2, 1], + ])('maps hostile %s failure to one fixed result', async ( + stage, + expectedSecondsReads, + expectedCoercionReads, + ) => { + let hostileReads = 0; + const hostileFailureTarget = Object.create(null); + Object.defineProperty(hostileFailureTarget, inspect.custom, { + value: () => { + hostileReads += 1; + throw new Error('hostile registration-sort formatter was inspected'); + }, + }); + const hostileFailure = new Proxy(hostileFailureTarget, { + get() { + hostileReads += 1; + throw new Error('hostile registration-sort getter was inspected'); + }, + getOwnPropertyDescriptor() { + hostileReads += 1; + throw new Error('hostile registration-sort descriptor was inspected'); + }, + ownKeys() { + hostileReads += 1; + throw new Error('hostile registration-sort keys were inspected'); + }, + }); + let secondsReads = 0; + let coercionReads = 0; + const coercibleSeconds = Object.create(null); + Object.defineProperty(coercibleSeconds, Symbol.toPrimitive, { + value: () => { + coercionReads += 1; + throw hostileFailure; + }, + }); + const documents = [ + registrationDocument({ + createdSeconds: 1_800_000_010, + eventId: 'synthetic-sort-event', + id: 'synthetic-sort-registration-a', + }), + registrationDocument({ + createdSeconds: 1_800_000_020, + eventId: 'synthetic-sort-event', + id: 'synthetic-sort-registration-b', + }), + ]; + documents.forEach((document) => { + const createdAt = document.data().createdAt; + document.data.mockClear(); + Object.defineProperty(createdAt, '_seconds', { + configurable: true, + get() { + secondsReads += 1; + if (stage === 'nested seconds access') throw hostileFailure; + return coercibleSeconds; + }, + }); + }); + admin.__seedRegistrations(documents); + admin.__seedEvent('synthetic-sort-event', { + location: 'Synthetic Sort Park', + slug: 'synthetic-sort-event', + startAt: { _seconds: 1_900_000_000 }, + title: 'Synthetic Sort Event', + }); + const logSpies = ['debug', 'error', 'info', 'log', 'warn'].map((method) => ( + jest.spyOn(console, method).mockImplementation(() => {}) + )); + + let caught; + let logCalls; + let resolved; + try { + try { + resolved = await listMyRegistrations({}, CONTEXT); + } catch (error) { + caught = error; + } + } finally { + logCalls = logSpies.map((spy) => spy.mock.calls.length); + logSpies.forEach((spy) => spy.mockRestore()); + } + const rawFailureEscaped = caught === hostileFailure; + const publicFailure = caught && !rawFailureEscaped ? { + causeAbsent: caught.cause === undefined, + code: caught.code, + detailsAbsent: caught.details === undefined, + message: caught.message, + } : null; + + expect({ + coercionReads, + eventCollectionCalls: admin.__eventCollectionCalls(), + eventLookupStages: admin.__eventLookupStages(), + eventReads: admin.__eventReads(), + eventSnapshotStages: admin.__eventSnapshotStages(), + functionLoggerCalls: firebaseFunctions.__loggerCalls(), + hostileReads, + logCalls, + publicFailure, + queries: admin.__queries(), + queryStages: admin.__registrationQueryStages(), + rawFailureEscaped, + registrationDataCalls: documents.map( + (document) => document.data.mock.calls.length, + ), + resolved, + secondsReads, + }).toEqual({ + coercionReads: expectedCoercionReads, + eventCollectionCalls: 1, + eventLookupStages: [ + 'collection', + 'doc:synthetic-sort-event', + 'get:synthetic-sort-event', + ], + eventReads: ['synthetic-sort-event'], + eventSnapshotStages: [ + 'exists:synthetic-sort-event', + 'data:synthetic-sort-event', + ], + functionLoggerCalls: 0, + hostileReads: 0, + logCalls: [0, 0, 0, 0, 0], + publicFailure: { + causeAbsent: true, + code: 'unavailable', + detailsAbsent: true, + message: 'Registration data could not be loaded.', + }, + queries: [{ + field: 'uid', + operator: '==', + value: CONTEXT.auth.uid, + }], + queryStages: ['firestore', 'collectionGroup', 'where', 'get'], + rawFailureEscaped: false, + registrationDataCalls: [2, 2], + resolved: undefined, + secondsReads: expectedSecondsReads, + }); + expect(requireAppCheck).toHaveBeenCalledTimes(1); + expect(requireAppCheck).toHaveBeenCalledWith(CONTEXT); + expect(admin.firestore).toHaveBeenCalledTimes(1); + }); + + test('preserves descending created-time order and missing-time fallback', async () => { + const missingTime = registrationDocument({ + createdSeconds: 1_800_000_030, + eventId: 'synthetic-sort-event', + id: 'synthetic-sort-registration-missing', + }); + const missingTimeRecord = missingTime.data(); + delete missingTimeRecord.createdAt; + missingTime.data.mockClear(); + admin.__seedRegistrations([ + missingTime, + registrationDocument({ + createdSeconds: 1_800_000_010, + eventId: 'synthetic-sort-event', + id: 'synthetic-sort-registration-older', + }), + registrationDocument({ + createdSeconds: 1_800_000_020, + eventId: 'synthetic-sort-event', + id: 'synthetic-sort-registration-newer', + }), + ]); + admin.__seedEvent('synthetic-sort-event', { + location: 'Synthetic Sort Park', + slug: 'synthetic-sort-event', + startAt: { _seconds: 1_900_000_000 }, + title: 'Synthetic Sort Event', + }); + + const result = await listMyRegistrations({}, CONTEXT); + + expect(result.registrations.map(({ createdAt, id }) => ({ createdAt, id }))).toEqual([ + { + createdAt: { _seconds: 1_800_000_020 }, + id: 'synthetic-sort-registration-newer', + }, + { + createdAt: { _seconds: 1_800_000_010 }, + id: 'synthetic-sort-registration-older', + }, + { + createdAt: null, + id: 'synthetic-sort-registration-missing', + }, + ]); + expect(admin.__eventReads()).toEqual(['synthetic-sort-event']); + expect(admin.__eventSnapshotStages()).toEqual([ + 'exists:synthetic-sort-event', + 'data:synthetic-sort-event', + ]); + expect(firebaseFunctions.__loggerCalls()).toBe(0); + }); +});