diff --git a/SECURITY.md b/SECURITY.md index 0deecd2..694413b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -143,6 +143,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | RISK-009, part of RISK-024 | OAUTH-001C1H [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) replaces browser-generated/session-storage-authoritative Strava OAuth state with a 32-byte server-issued challenge. Source stores only a SHA-256 digest in one browser-denied per-UID secret record, binds it to exact UID and decoded Auth `auth_time`, expires it after ten minutes, and transactionally deletes one match before provider exchange or connection writes. A later begin overwrites the older record; mismatch, expiry, replay, and the concurrent loser stop with one fixed result before Strava. Synthetic tests use mocks and no real callback or provider value. | This source does not deploy either Function or the website, configure Strava, prove the custom App Check guard is enabled, enable native runtime App Check enforcement, inspect production data, or prove live OAuth behavior. #443 separately supplies only the source/test clean-page readiness handoff while preserving initial callback suppression. Scope/account policy, refresh concurrency, provider/local reconciliation, revoke/audit, IAM/encryption, configured Enterprise evidence, native enforcement, and provider/live evidence remain open under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88). | | RISK-008, RISK-009, part of RISK-024 | OAUTH-001C1I [#443](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/443) preserves initial capability-callback App Check suppression, then permits only a document that began on a normalized Strava callback to initialize the existing Enterprise provider after both current callback locations are clean, Router page state is `null`, and native history state is absent or the exact matching BrowserRouter index/key/empty-user-state record. One single-flight readiness attempt precedes one existing exchange. Before exchange admission, the callback rechecks the clean path, Router entry key, and exact UID, service, Firebase resources, app, attempt, and page lifecycle; dirty or reinjected paths, extra or mismatched history state, missing configuration, provider/readiness failure, or stale mounted context use one fixed failure and make no exchange, while an unmounted completion is inert. Authentication, registration, and shop callbacks are ineligible and keep their existing flow. The method never receives callback values or returns, inspects, logs, or stores the token-ready result. Local/test remain off and ordinary production startup remains eager. | This is source and synthetic-test ordering evidence only. It does not configure an Enterprise key or domains, prove a provider-backed token or Firebase acceptance, enable native `enforceAppCheck`, change a Function or Rule, publish the website, deploy Firebase, configure or contact Strava, inspect production data, or prove live OAuth behavior. App Check initialization is not reversible; later reinjection is scrubbed/discarded but earlier browser, hosting, provider, and network copies are not erased. A lifecycle or reinjection change after exchange admission cannot cancel server or provider work already started; it blocks another exchange and obsolete UI, but reconciliation may still be required. Complete ABUSE-001A2 only after protected provider and exact-revision staging evidence. Account/scope policy, refresh concurrency, reconciliation, revoke/audit, IAM/encryption, deployment, and live proof keep [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) open. | | RISK-009, part of RISK-024 | OAUTH-001C1J [#612](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/612) browser source admits a fulfilled Strava exchange result only when it presents exactly two own fields, each an enumerable data field: primitive `ok` equal to `true` and a positive safe-integer `athleteId`. It returns a fresh frozen confirmation. Every other fulfillment or exceptional result inspection throws one fixed local response error; a current callback uses the existing fixed failure screen without navigating, while existing lifecycle guards keep obsolete completions inert. Callable transport rejection remains uninspected and identity-preserving. Browser JavaScript cannot distinguish a transparent Proxy that faithfully presents the contract, so the received object is never retained. The check relies on the page realm's built-in reflection, numeric, and freeze methods; code that replaces those methods before admission can change what this browser-only boundary observes. Synthetic tests use no real callback, account, token, provider, or Firebase project. | This client acknowledgement check does not prove Strava origin, provider exchange, paired Firestore persistence, athlete ownership, approved scopes, server integrity, Firebase or website deployment, or live behavior. A compromised server or browser realm can forge the exact result. Provider response-byte and timeout limits, scope/account policy, provider/local reconciliation, audit, IAM/encryption, configured Enterprise evidence, native enforcement, release order, and provider/live proof remain open under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88). | +| RISK-017, part of RISK-024 | OAUTH-001A2K [#614](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/614) Function source admits a successful Strava authorization-exchange or refresh body only through the captured native Node `Response`, `Headers`, and stream-reader methods. It permits at most 65,536 content-decoded bytes and 4,096 chunks before copy, treats a valid `Content-Length` only as an early ceiling check, counts the actual chunks even when that header is missing or lower, best-effort cancels an unfinished failure without awaiting or exposing cleanup, then uses fatal UTF-8 decoding and captured JSON parsing before the existing strict projector. It never calls the response's `json`, `text`, `arrayBuffer`, or `bytes` method, clone, or tee. Invalid body transport/parsing keeps the existing fixed unavailable result; a bounded invalid schema keeps the fixed internal result. Exchange state stays consumed but no token/connection write follows a body failure; refresh keeps the prior secret and starts no bearer data request. The direct projector seam remains absent from `functions/index.js` and preserves hostile-JavaScript tests that real JSON bytes cannot construct. Synthetic tests use no real token, account, provider, or Firebase project. | This application ceiling covers only content-decoded chunks consumed by this source. It does not bound headers, compressed-wire bytes, Undici buffers, one already-allocated provider chunk, parsed-object heap growth, CPU, or request/connect/read time. Cancellation cannot undo a provider exchange or rotation, so a lost acknowledgement can still require reconciliation and must not be retried blindly. Activities/statistics response bounds and their concurrently started body cleanup remain open, as do scope/account policy, refresh/revoke reconciliation, audit, IAM/encryption, Firebase deployment, provider configuration, production data, and provider/live proof under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88). | | Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) routed the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibited direct console calls elsewhere in application/public runtime source. OBS-001A5 [#494](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/494) removes the dormant members-only identifier after its sole caller was retired, leaving the current helper with four fixed outcomes and requiring the old literal to be dropped as unknown input. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. #443 completes only the source/test Strava client handoff; native enforcement and the DATA-001A confirmation handoffs remain with ABUSE-001A and their canonical trackers. | | Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) merged through [PR #189](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/189) as `bec7d5e365eacb418563a172029f241f660d9768`, adding the no-new-dependency exact-record checker, named blocking lint step, and clean-checkout step. Exact PR run `29291402007` and post-merge run `29291515653` passed every GitHub job. CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227), CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239), and PAY-004C1 [#359](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/359) retire four errors and one warning in focused reviewed changes. [PR #391](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/391) and [PR #392](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/392) add three TypeScript files while retiring four TSX errors during reviewed functional work. CI-001B4C [#449](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/449) retires exactly four stale unknown-rule directive records with comment-only same-line substitutions. #492 removes one finding-free dormant JSX file, #505 adds four finding-free TypeScript/TSX files, and #506 adds four more. | The checker now scans 118 frontend files. Its reviewable baseline deliberately records 113 configured errors and 6 warnings as individual file/line/column/rule/message records; it is not a clean-lint claim. Any added, removed, moved, or changed record requires review, every fatal parser/configuration failure rejects with a fixed diagnostic, and the baseline must not be regenerated just to pass. The clean check covers tracked and non-ignored untracked files; ignored dependency/build outputs are outside this proof. Hosted source checks do not configure branch protection, deploy Firebase, or prove Stripe/live behavior. #169, #173, #182, #206, #226, #232, and #238 expand the focused commerce suite; configure/prove branch-required checks and add the broader TEST-001 suite. | | DATA-001A3 / registration confidentiality | DATA-001A3 [#374](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/374) limits the My Account registration callable to one exact stored-UID query. Source no longer reads token email or verification claims and no longer searches registrations by runner email. Matching-email records with no UID or another account's UID are excluded; exact-UID records retain their existing sanitized summary, event lookup, and sort. The page's empty result says only that no upcoming registration is linked and that a signed-out registration may not appear. Synthetic tests use getter and Proxy canaries and make no write or provider call. | This source containment is not deployed or live and does not associate, migrate, repair, or change a registration. Anonymous, historical, mistyped-email, shared-email, and differently owned records stay hidden until a separately approved, audited server association under [#115](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/115). Keep the email index until separate inventory and migration review. Complete protected Function deployment/readback, website publication, exact `runmprc.com` verification, and a made-up account check before describing the boundary as live. | diff --git a/functions/strava.js b/functions/strava.js index 3064f93..2eb4894 100644 --- a/functions/strava.js +++ b/functions/strava.js @@ -3,7 +3,7 @@ const admin = require('firebase-admin'); const { Timestamp } = require('firebase-admin/firestore'); const { createHash, randomBytes, timingSafeEqual } = require('node:crypto'); const { URL: NodeURL, URLSearchParams: NodeURLSearchParams } = require('node:url'); -const { types: { isProxy } } = require('node:util'); +const { TextDecoder, types: { isProxy, isUint8Array } } = require('node:util'); const { requireAppCheck } = require('./stripeHelpers'); const STRAVA_TOKEN_URL = 'https://www.strava.com/api/v3/oauth/token'; @@ -17,6 +17,8 @@ const STRAVA_OAUTH_STATE_LENGTH = 43; const STRAVA_OAUTH_STATE_LIFETIME_SECONDS = 600; const STRAVA_OAUTH_STATE_SCHEMA_VERSION = 1; const STRAVA_TOKEN_MAX_LENGTH = 2_048; +const STRAVA_TOKEN_RESPONSE_MAX_BYTES = 65_536; +const STRAVA_RESPONSE_MAX_CHUNKS = 4_096; const STRAVA_SCOPE_MAX_LENGTH = 1_024; const STRAVA_PROFILE_TEXT_MAX_LENGTH = 1_024; const STRAVA_PROFILE_URL_MAX_LENGTH = 2_048; @@ -32,6 +34,7 @@ const STRAVA_DISCONNECT_ERROR_MESSAGE = 'Strava disconnect could not be confirme const VISIBLE_ASCII_PATTERN = /^[\x21-\x7e]+$/; const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u; +const CONTENT_LENGTH_PATTERN = /^(?:0|[1-9][0-9]*)$/u; const OAUTH_SCOPE_PATTERN = /^[\x21\x23-\x5b\x5d-\x7e]+(?: [\x21\x23-\x5b\x5d-\x7e]+)*$/; const PROFILE_WHITESPACE_OR_BACKSLASH_PATTERN = /[\s\\]/u; const HTTPS_PREFIX_PATTERN = /^https:\/\//iu; @@ -43,19 +46,68 @@ const objectPrototype = Object.prototype; const arrayIsArray = Array.isArray; const arrayPrototype = Array.prototype; const arrayPush = Array.prototype.push; +const bufferConcat = Buffer.concat; +const bufferFrom = Buffer.from; +const jsonParse = JSON.parse; +const numberFromString = Number; const numberIsFinite = Number.isFinite; const numberIsSafeInteger = Number.isSafeInteger; +const promiseConstructor = Promise; +const promiseResolve = Promise.resolve; +const promiseThen = Promise.prototype.then; const reflectApply = Reflect.apply; const reflectHas = Reflect.has; const reflectOwnKeys = Reflect.ownKeys; const regexpTest = RegExp.prototype.test; const stringCharCodeAt = String.prototype.charCodeAt; +const textDecoderDecode = TextDecoder.prototype.decode; +const typedArrayPrototype = objectGetPrototypeOf(Uint8Array.prototype); +const typedArrayByteLengthGetter = objectGetOwnPropertyDescriptor( + typedArrayPrototype, + 'byteLength', +)?.get; const NATIVE_RESPONSE_PROTOTYPE = typeof Response === 'function' ? Response.prototype : null; const NATIVE_RESPONSE_OK_GETTER = NATIVE_RESPONSE_PROTOTYPE ? objectGetOwnPropertyDescriptor(NATIVE_RESPONSE_PROTOTYPE, 'ok')?.get : null; +const NATIVE_RESPONSE_HEADERS_GETTER = NATIVE_RESPONSE_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_RESPONSE_PROTOTYPE, 'headers')?.get + : null; +const NATIVE_RESPONSE_BODY_GETTER = NATIVE_RESPONSE_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_RESPONSE_PROTOTYPE, 'body')?.get + : null; +const NATIVE_RESPONSE_BODY_USED_GETTER = NATIVE_RESPONSE_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_RESPONSE_PROTOTYPE, 'bodyUsed')?.get + : null; +const NATIVE_HEADERS_PROTOTYPE = typeof Headers === 'function' + ? Headers.prototype + : null; +const NATIVE_HEADERS_GET = NATIVE_HEADERS_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_HEADERS_PROTOTYPE, 'get')?.value + : null; +const NATIVE_READABLE_STREAM_PROTOTYPE = typeof ReadableStream === 'function' + ? ReadableStream.prototype + : null; +const NATIVE_STREAM_GET_READER = NATIVE_READABLE_STREAM_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_READABLE_STREAM_PROTOTYPE, 'getReader')?.value + : null; +const NATIVE_STREAM_CANCEL = NATIVE_READABLE_STREAM_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_READABLE_STREAM_PROTOTYPE, 'cancel')?.value + : null; +const NATIVE_READER_PROTOTYPE = typeof ReadableStreamDefaultReader === 'function' + ? ReadableStreamDefaultReader.prototype + : null; +const NATIVE_READER_READ = NATIVE_READER_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_READER_PROTOTYPE, 'read')?.value + : null; +const NATIVE_READER_CANCEL = NATIVE_READER_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_READER_PROTOTYPE, 'cancel')?.value + : null; +const NATIVE_READER_RELEASE_LOCK = NATIVE_READER_PROTOTYPE + ? objectGetOwnPropertyDescriptor(NATIVE_READER_PROTOTYPE, 'releaseLock')?.value + : null; const INVALID_SELECTED_VALUE = Symbol('invalid-selected-value'); const MISSING_SELECTED_VALUE = Symbol('missing-selected-value'); const INVALID_AUTHORIZATION_REQUEST = Symbol('invalid-authorization-request'); @@ -181,6 +233,183 @@ function isConfirmedStravaHttpSuccess(response) { } } +function isExactNativeValue(value, prototype) { + if ( + prototype === null + || value === null + || typeof value !== 'object' + || isProxy(value) + ) { + return false; + } + try { + return objectGetPrototypeOf(value) === prototype; + } catch (_error) { + return false; + } +} + +function suppressPromiseRejection(value) { + try { + const promise = reflectApply(promiseResolve, promiseConstructor, [value]); + reflectApply(promiseThen, promise, [undefined, () => undefined]); + } catch (_error) { + // Best-effort cleanup must never replace the fixed caller result. + } +} + +function cancelNativeStream(stream) { + if (typeof NATIVE_STREAM_CANCEL !== 'function') return; + try { + suppressPromiseRejection(reflectApply(NATIVE_STREAM_CANCEL, stream, [])); + } catch (_error) { + // Best-effort cleanup must never replace the fixed caller result. + } +} + +function cancelNativeReader(reader) { + if (typeof NATIVE_READER_CANCEL !== 'function') return; + try { + suppressPromiseRejection(reflectApply(NATIVE_READER_CANCEL, reader, [])); + } catch (_error) { + // Best-effort cleanup must never replace the fixed caller result. + } +} + +function releaseNativeReader(reader) { + if (typeof NATIVE_READER_RELEASE_LOCK !== 'function') return; + try { + reflectApply(NATIVE_READER_RELEASE_LOCK, reader, []); + } catch (_error) { + // Lock cleanup must never replace the fixed caller result. + } +} + +function nativeResponseParts(response) { + if ( + !isExactNativeValue(response, NATIVE_RESPONSE_PROTOTYPE) + || typeof NATIVE_RESPONSE_HEADERS_GETTER !== 'function' + || typeof NATIVE_RESPONSE_BODY_GETTER !== 'function' + || typeof NATIVE_RESPONSE_BODY_USED_GETTER !== 'function' + || typeof NATIVE_HEADERS_GET !== 'function' + || typeof NATIVE_STREAM_GET_READER !== 'function' + || typeof NATIVE_READER_READ !== 'function' + || typeof typedArrayByteLengthGetter !== 'function' + ) { + return null; + } + + try { + const headers = reflectApply(NATIVE_RESPONSE_HEADERS_GETTER, response, []); + const body = reflectApply(NATIVE_RESPONSE_BODY_GETTER, response, []); + const bodyUsed = reflectApply(NATIVE_RESPONSE_BODY_USED_GETTER, response, []); + if ( + bodyUsed !== false + || !isExactNativeValue(headers, NATIVE_HEADERS_PROTOTYPE) + || !isExactNativeValue(body, NATIVE_READABLE_STREAM_PROTOTYPE) + ) { + return null; + } + return objectFreeze({ body, headers }); + } catch (_error) { + return null; + } +} + +function declaredContentLength(headers, maximumBytes) { + const value = reflectApply(NATIVE_HEADERS_GET, headers, ['content-length']); + if (value === null) return null; + if (typeof value !== 'string' || !patternMatches(CONTENT_LENGTH_PATTERN, value)) { + throw new Error('invalid Strava response length'); + } + const length = reflectApply(numberFromString, undefined, [value]); + if (!numberIsSafeInteger(length) || length > maximumBytes) { + throw new Error('invalid Strava response length'); + } + return length; +} + +function streamReadResult(record) { + if ( + record === null + || typeof record !== 'object' + || isProxy(record) + || !hasExactOwnKeys(record, ['value', 'done']) + ) { + return null; + } + const value = selectedOwnDataValue(record, 'value', true); + const done = selectedOwnDataValue(record, 'done', true); + if (typeof done !== 'boolean') return null; + if (done === true) { + return value === undefined ? objectFreeze({ done: true }) : null; + } + return objectFreeze({ done: false, value }); +} + +async function readBoundedStravaJson(response, maximumBytes) { + let body = null; + let reader = null; + let reachedEnd = false; + try { + if (!numberIsSafeInteger(maximumBytes) || maximumBytes <= 0) { + throw new Error('invalid Strava response ceiling'); + } + const parts = nativeResponseParts(response); + if (!parts) throw new Error('invalid Strava response body'); + body = parts.body; + declaredContentLength(parts.headers, maximumBytes); + + reader = reflectApply(NATIVE_STREAM_GET_READER, body, []); + if (!isExactNativeValue(reader, NATIVE_READER_PROTOTYPE)) { + throw new Error('invalid Strava response reader'); + } + + const chunks = []; + let chunkCount = 0; + let totalBytes = 0; + for (;;) { + const record = streamReadResult(await reflectApply(NATIVE_READER_READ, reader, [])); + if (!record) throw new Error('invalid Strava response read'); + if (record.done) { + reachedEnd = true; + break; + } + + chunkCount += 1; + if (chunkCount > STRAVA_RESPONSE_MAX_CHUNKS) { + throw new Error('invalid Strava response chunks'); + } + const chunk = record.value; + if (!isUint8Array(chunk) || isProxy(chunk)) { + throw new Error('invalid Strava response chunk'); + } + const chunkBytes = reflectApply(typedArrayByteLengthGetter, chunk, []); + if (chunkBytes > maximumBytes - totalBytes) { + throw new Error('invalid Strava response size'); + } + reflectApply(arrayPush, chunks, [reflectApply(bufferFrom, Buffer, [chunk])]); + totalBytes += chunkBytes; + } + + const bytes = reflectApply(bufferConcat, Buffer, [chunks, totalBytes]); + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); + const text = reflectApply(textDecoderDecode, decoder, [bytes]); + return reflectApply(jsonParse, undefined, [text]); + } catch (_error) { + if (!reachedEnd) { + if (reader) { + cancelNativeReader(reader); + } else if (body) { + cancelNativeStream(body); + } + } + throw new Error('Invalid Strava provider JSON response.'); + } finally { + if (reader) releaseNativeReader(reader); + } +} + function isBoundedVisibleAscii(value, maxLength) { return typeof value === 'string' && value.length > 0 @@ -836,7 +1065,7 @@ async function exchangeCode(code) { } let response; try { - response = await resp.json(); + response = await readBoundedStravaJson(resp, STRAVA_TOKEN_RESPONSE_MAX_BYTES); } catch (_error) { throw stravaAuthorizationError('unavailable'); } @@ -875,7 +1104,7 @@ async function refreshToken(refresh) { } let response; try { - response = await resp.json(); + response = await readBoundedStravaJson(resp, STRAVA_TOKEN_RESPONSE_MAX_BYTES); } catch (_error) { throw stravaRefreshError('unavailable'); } @@ -1199,3 +1428,11 @@ exports.stravaDisconnect = functions } return { ok: true }; }); + +// Not re-exported by functions/index.js; keeps hostile projector cases directly testable. +exports.__testOnlyStravaProviderBoundaries = objectFreeze({ + readBoundedStravaJson, + snapshotAuthorizationExchangeResponse, + snapshotRefreshTokenResponse, + tokenResponseMaxBytes: STRAVA_TOKEN_RESPONSE_MAX_BYTES, +}); diff --git a/functions/strava.test.js b/functions/strava.test.js index 321d664..d1792d3 100644 --- a/functions/strava.test.js +++ b/functions/strava.test.js @@ -434,11 +434,18 @@ const { Timestamp } = require('firebase-admin/firestore'); const { createHash } = require('node:crypto'); const { requireAppCheck } = require('./stripeHelpers'); const { + __testOnlyStravaProviderBoundaries, stravaBeginAuthorization, stravaDisconnect, stravaExchangeCode, stravaFetchStats, } = require('./strava'); +const { + readBoundedStravaJson, + snapshotAuthorizationExchangeResponse, + snapshotRefreshTokenResponse, + tokenResponseMaxBytes, +} = __testOnlyStravaProviderBoundaries; const FIXED_AUTHORIZATION_ERROR = 'Strava authorization could not be completed.'; const FIXED_REFRESH_ERROR = 'Strava connection could not be refreshed.'; @@ -459,6 +466,7 @@ const CONTEXT = Object.freeze({ const CODE = 'synthetic_authorization_code'; const STATE = 'A'.repeat(43); const MAX_AUTHORIZATION_CODE_LENGTH = 1_024; +const MAX_STRAVA_TOKEN_RESPONSE_BYTES = 65_536; const MAX_TOKEN_LENGTH = 2_048; const MAX_SCOPE_LENGTH = 1_024; const MAX_PROFILE_TEXT_LENGTH = 1_024; @@ -493,6 +501,415 @@ function sha256Hex(value) { return createHash('sha256').update(value, 'utf8').digest('hex'); } +function nativeChunkedResponse(chunks, { + cancel = jest.fn(), + headers, + onChunk, + status = 200, + stayOpen = false, +} = {}) { + const chunkRead = jest.fn((chunk, index) => onChunk?.(chunk, index)); + let chunkIndex = 0; + let streamController; + const body = new ReadableStream({ + start(controller) { + streamController = controller; + }, + pull(controller) { + if (chunkIndex < chunks.length) { + const chunk = chunks[chunkIndex]; + chunkRead(chunk, chunkIndex); + chunkIndex += 1; + controller.enqueue(chunk); + return; + } + if (!stayOpen) controller.close(); + }, + cancel, + }, { highWaterMark: 0 }); + return { + cancel, + chunkRead, + close: () => streamController.close(), + response: new Response(body, { headers, status }), + }; +} + +function nativeJsonResponse(value, { headers, onBodyRead, status = 200 } = {}) { + const bytes = Buffer.from(JSON.stringify(value), 'utf8'); + const native = nativeChunkedResponse([bytes], { + headers, + onChunk: onBodyRead, + status, + }); + return { ...native, bodyRead: native.chunkRead, bytes }; +} + +function paddedJsonBytes(value, byteLength) { + const serialized = Buffer.from(JSON.stringify(value), 'utf8'); + if (serialized.byteLength > byteLength) { + throw new Error('Requested fixture length is shorter than its JSON document.'); + } + return Buffer.concat([ + serialized, + Buffer.alloc(byteLength - serialized.byteLength, 0x20), + ]); +} + +function isolatedStravaBoundariesWithPatch(target, key, replacement) { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + let isolatedBoundaries; + Object.defineProperty(target, key, { ...descriptor, value: replacement }); + try { + jest.isolateModules(() => { + ({ __testOnlyStravaProviderBoundaries: isolatedBoundaries } = require('./strava')); + }); + } finally { + Object.defineProperty(target, key, descriptor); + } + return isolatedBoundaries; +} + +describe('OAUTH-001A2K bounded native Strava token JSON reader', () => { + test('uses the named 64 KiB token-response ceiling and reads ordinary JSON bytes', async () => { + const native = nativeJsonResponse({ ok: true }); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .resolves.toEqual({ ok: true }); + expect(tokenResponseMaxBytes).toBe(MAX_STRAVA_TOKEN_RESPONSE_BYTES); + expect(native.bodyRead).toHaveBeenCalledTimes(1); + expect(native.response.bodyUsed).toBe(true); + }); + + test.each([ + ['one byte below', MAX_STRAVA_TOKEN_RESPONSE_BYTES - 1], + ['exactly at', MAX_STRAVA_TOKEN_RESPONSE_BYTES], + ])('accepts a valid JSON document %s the byte ceiling', async (_case, byteLength) => { + const bytes = paddedJsonBytes({ admitted: true }, byteLength); + const native = nativeChunkedResponse([bytes]); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .resolves.toEqual({ admitted: true }); + expect(bytes).toHaveLength(byteLength); + expect(native.chunkRead).toHaveBeenCalledTimes(1); + expect(native.cancel).not.toHaveBeenCalled(); + }); + + test('rejects and cancels a one-chunk body one byte over the ceiling', async () => { + const bytes = paddedJsonBytes({ admitted: false }, MAX_STRAVA_TOKEN_RESPONSE_BYTES + 1); + const native = nativeChunkedResponse([bytes]); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + await Promise.resolve(); + expect(native.chunkRead).toHaveBeenCalledTimes(1); + expect(native.cancel).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['one overflowing chunk', [Buffer.alloc(MAX_STRAVA_TOKEN_RESPONSE_BYTES + 1)], []], + [ + 'a final overflowing chunk', + [Buffer.alloc(MAX_STRAVA_TOKEN_RESPONSE_BYTES), Buffer.alloc(1)], + [MAX_STRAVA_TOKEN_RESPONSE_BYTES], + ], + ])('does not copy %s', async (_case, chunks, expectedCopiedChunkSizes) => { + const copiedChunkSizes = []; + const originalBufferFrom = Buffer.from; + const instrumentedBufferFrom = function instrumentedBufferFrom(value, ...rest) { + if (value instanceof Uint8Array && rest.length === 0) { + copiedChunkSizes.push(value.byteLength); + } + return Reflect.apply(originalBufferFrom, Buffer, [value, ...rest]); + }; + const isolated = isolatedStravaBoundariesWithPatch( + Buffer, + 'from', + instrumentedBufferFrom, + ); + copiedChunkSizes.length = 0; + const native = nativeChunkedResponse(chunks); + + await expect(isolated.readBoundedStravaJson( + native.response, + isolated.tokenResponseMaxBytes, + )).rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + await Promise.resolve(); + expect(copiedChunkSizes).toEqual(expectedCopiedChunkSizes); + expect(native.chunkRead).toHaveBeenCalledTimes(chunks.length); + expect(native.cancel).toHaveBeenCalledTimes(1); + }); + + test('accepts absent, truthful, and underreported Content-Length values', async () => { + const ordinaryBytes = Buffer.from('{"admitted":true}', 'utf8'); + const cases = [ + nativeChunkedResponse([ordinaryBytes]), + nativeChunkedResponse([ordinaryBytes], { + headers: { 'Content-Length': String(ordinaryBytes.byteLength) }, + }), + nativeChunkedResponse([ordinaryBytes], { headers: { 'Content-Length': '1' } }), + ]; + + for (const native of cases) { + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .resolves.toEqual({ admitted: true }); + expect(native.chunkRead).toHaveBeenCalledTimes(1); + } + }); + + test('rejects a declared over-limit length before reading the body', async () => { + const native = nativeChunkedResponse([Buffer.from('{}')], { + headers: { 'Content-Length': String(MAX_STRAVA_TOKEN_RESPONSE_BYTES + 1) }, + }); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + await Promise.resolve(); + expect(native.chunkRead).not.toHaveBeenCalled(); + expect(native.cancel).toHaveBeenCalledTimes(1); + }); + + test('still rejects actual overflow behind an exact but dishonest Content-Length', async () => { + const bytes = paddedJsonBytes({}, MAX_STRAVA_TOKEN_RESPONSE_BYTES + 1); + const native = nativeChunkedResponse([bytes], { + headers: { 'Content-Length': String(MAX_STRAVA_TOKEN_RESPONSE_BYTES) }, + }); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + expect(native.chunkRead).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['negative', '-1'], + ['fractional', '1.5'], + ['leading-zero', '01'], + ['signed', '+1'], + ['unsafe', '9007199254740992'], + ['non-decimal', '1e2'], + ])('rejects a %s Content-Length before body reads', async (_case, contentLength) => { + const native = nativeChunkedResponse([Buffer.from('{}')], { + headers: { 'Content-Length': contentLength }, + }); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + expect(native.chunkRead).not.toHaveBeenCalled(); + }); + + test('decodes a multibyte UTF-8 scalar split across chunks', async () => { + const bytes = Buffer.from(JSON.stringify({ runner: '🏃' }), 'utf8'); + const scalar = Buffer.from('🏃', 'utf8'); + const splitIndex = bytes.indexOf(scalar) + 2; + const native = nativeChunkedResponse([ + bytes.subarray(0, splitIndex), + bytes.subarray(splitIndex), + ]); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .resolves.toEqual({ runner: '🏃' }); + expect(native.chunkRead).toHaveBeenCalledTimes(2); + }); + + test('matches native JSON BOM handling', async () => { + const bytes = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from('{"admitted":true}', 'utf8'), + ]); + const native = nativeChunkedResponse([bytes]); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .resolves.toEqual({ admitted: true }); + }); + + test('counts UTF-8 bytes rather than JavaScript characters', async () => { + const text = JSON.stringify({ value: 'é'.repeat(32_766) }); + const bytes = Buffer.from(text, 'utf8'); + const native = nativeChunkedResponse([bytes]); + + expect(text.length).toBeLessThan(MAX_STRAVA_TOKEN_RESPONSE_BYTES); + expect(bytes.byteLength).toBeGreaterThan(MAX_STRAVA_TOKEN_RESPONSE_BYTES); + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + }); + + test('rejects malformed UTF-8 before JSON parsing', async () => { + const native = nativeChunkedResponse([Buffer.from([0x7b, 0xc3, 0x28, 0x7d])]); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + }); + + test('rejects empty, null, locked, and already-used bodies', async () => { + const empty = nativeChunkedResponse([]); + const nullBody = new Response(null, { status: 200 }); + const locked = nativeJsonResponse({ admitted: true }); + const lock = locked.response.body.getReader(); + const used = nativeJsonResponse({ admitted: true }); + await used.response.text(); + + try { + for (const response of [empty.response, nullBody, locked.response, used.response]) { + await expect(readBoundedStravaJson(response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + } + } finally { + lock.releaseLock(); + } + }); + + test.each([ + ['a string', '{}'], + ['an ArrayBuffer', new ArrayBuffer(2)], + ['a non-byte typed array', new Uint16Array([123])], + ['a proxied Uint8Array', new Proxy(new Uint8Array([123, 125]), {})], + ])('rejects %s stream chunks', async (_case, chunk) => { + const native = nativeChunkedResponse([chunk]); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + }); + + test.each([ + ['an undefined record', undefined], + ['a missing value', { done: false }], + ['a non-boolean done', { value: Buffer.from('{}'), done: 0 }], + ['an extra field', { value: Buffer.from('{}'), done: false, extra: true }], + ['a terminal value', { value: Buffer.from('{}'), done: true }], + ])('rejects %s returned by the native reader', async (_case, record) => { + const isolated = isolatedStravaBoundariesWithPatch( + ReadableStreamDefaultReader.prototype, + 'read', + function malformedRead() { + return Promise.resolve(record); + }, + ); + const native = nativeChunkedResponse([Buffer.from('{}')]); + + await expect(isolated.readBoundedStravaJson( + native.response, + isolated.tokenResponseMaxBytes, + )).rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + }); + + test('rejects an accessor-backed reader result without invoking it', async () => { + const valueRead = jest.fn(() => Buffer.from('{}')); + const record = { done: false }; + Object.defineProperty(record, 'value', { + configurable: true, + enumerable: true, + get: valueRead, + }); + const isolated = isolatedStravaBoundariesWithPatch( + ReadableStreamDefaultReader.prototype, + 'read', + function accessorRead() { + return Promise.resolve(record); + }, + ); + const native = nativeChunkedResponse([Buffer.from('{}')]); + + await expect(isolated.readBoundedStravaJson( + native.response, + isolated.tokenResponseMaxBytes, + )).rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + expect(valueRead).not.toHaveBeenCalled(); + }); + + test('rejects a stream read failure without exposing its reason', async () => { + const response = new Response(new ReadableStream({ + pull() { + throw new Error('provider-read-canary https://provider.test/token'); + }, + }, { highWaterMark: 0 }), { status: 200 }); + + await expect(readBoundedStravaJson(response, tokenResponseMaxBytes)) + .rejects.toEqual(new Error('Invalid Strava provider JSON response.')); + }); + + test.each([ + ['throws', () => { throw new Error('cancel-throw-canary'); }], + ['rejects', () => Promise.reject(new Error('cancel-reject-canary'))], + ['never settles', () => new Promise(() => {})], + ])('keeps the fixed result when cancellation %s', async (_case, cancelBehavior) => { + const cancel = jest.fn(cancelBehavior); + const native = nativeChunkedResponse( + [Buffer.alloc(MAX_STRAVA_TOKEN_RESPONSE_BYTES + 1)], + { cancel }, + ); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toEqual(new Error('Invalid Strava provider JSON response.')); + await Promise.resolve(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + test('does not let a lock-release failure replace valid parsed JSON', async () => { + const isolated = isolatedStravaBoundariesWithPatch( + ReadableStreamDefaultReader.prototype, + 'releaseLock', + function failingReleaseLock() { + throw new Error('release-lock-canary'); + }, + ); + const native = nativeJsonResponse({ admitted: true }); + + await expect(isolated.readBoundedStravaJson( + native.response, + isolated.tokenResponseMaxBytes, + )).resolves.toEqual({ admitted: true }); + }); + + test('rejects and cancels on the 4,097th zero-byte chunk', async () => { + const chunks = Array.from({ length: 4_097 }, () => new Uint8Array(0)); + const native = nativeChunkedResponse(chunks); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + await Promise.resolve(); + expect(native.chunkRead).toHaveBeenCalledTimes(4_097); + expect(native.cancel).toHaveBeenCalledTimes(1); + }); + + test('does not parse until the final stream read reaches end', async () => { + const native = nativeChunkedResponse([Buffer.from('{"admitted":true}')], { + stayOpen: true, + }); + let settled = false; + const result = readBoundedStravaJson(native.response, tokenResponseMaxBytes) + .finally(() => { settled = true; }); + + await Promise.resolve(); + await Promise.resolve(); + expect(native.chunkRead).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + native.close(); + await expect(result).resolves.toEqual({ admitted: true }); + }); + + test('does not invoke an override or a plain mock JSON method', async () => { + const native = nativeJsonResponse({ admitted: true }); + const nativeJson = jest.fn(() => { + throw new Error('native override canary'); + }); + Object.defineProperty(native.response, 'json', { + configurable: true, + value: nativeJson, + }); + const plainJson = jest.fn().mockResolvedValue({ admitted: true }); + + await expect(readBoundedStravaJson(native.response, tokenResponseMaxBytes)) + .resolves.toEqual({ admitted: true }); + await expect(readBoundedStravaJson( + { ok: true, json: plainJson }, + tokenResponseMaxBytes, + )).rejects.toThrow(/^Invalid Strava provider JSON response\.$/u); + expect(nativeJson).not.toHaveBeenCalled(); + expect(plainJson).not.toHaveBeenCalled(); + }); +}); + function validOAuthStateRecord({ state = STATE, uid = CONTEXT.auth.uid, @@ -831,18 +1248,185 @@ describe('Strava authorization exchange failure boundary', () => { } function mockExchangeResponse(response) { - const json = jest.fn().mockResolvedValue(response); - fetchMock.mockResolvedValue({ - ok: true, - json, - }); - return json; + const native = nativeJsonResponse(response); + fetchMock.mockResolvedValue(native.response); + return native.bodyRead; } function mockSuccessfulExchange() { return mockExchangeResponse(validExchangeResponse()); } + function oversizedNativeExchangeResponse() { + const serialized = Buffer.from(JSON.stringify(validExchangeResponse()), 'utf8'); + const bytes = Buffer.concat([ + serialized, + Buffer.alloc(MAX_STRAVA_TOKEN_RESPONSE_BYTES + 1 - serialized.length, 0x20), + ]); + const chunks = [ + bytes.subarray(0, MAX_STRAVA_TOKEN_RESPONSE_BYTES), + bytes.subarray(MAX_STRAVA_TOKEN_RESPONSE_BYTES), + ]; + const cancel = jest.fn(); + const chunkRead = jest.fn(); + let chunkIndex = 0; + const body = new ReadableStream({ + pull(controller) { + if (chunkIndex < chunks.length) { + chunkRead(); + controller.enqueue(chunks[chunkIndex]); + chunkIndex += 1; + return; + } + controller.close(); + }, + cancel, + }, { highWaterMark: 0 }); + const response = new Response(body, { status: 200 }); + Object.defineProperty(response, 'json', { + configurable: true, + value: async () => JSON.parse(await Response.prototype.text.call(response)), + }); + + expect(bytes).toHaveLength(MAX_STRAVA_TOKEN_RESPONSE_BYTES + 1); + expect(chunks.map((chunk) => chunk.byteLength)).toEqual([ + MAX_STRAVA_TOKEN_RESPONSE_BYTES, + 1, + ]); + expect(response.headers.has('content-length')).toBe(false); + return { cancel, chunkRead, response }; + } + + test('OAUTH-001A2K cancels an oversized chunked authorization response before parsing', async () => { + const { cancel, chunkRead, response } = oversizedNativeExchangeResponse(); + fetchMock.mockResolvedValue(response); + + const outcome = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT).then( + (value) => ({ kind: 'fulfilled', value }), + (error) => ({ error: publicError(error), kind: 'rejected' }), + ); + await Promise.resolve(); + + expect({ + cancelCount: cancel.mock.calls.length, + connectionExists: admin.__hasDocument(CONNECTION_PATH), + logCallCounts: consoleSpies.map((spy) => spy.mock.calls.length), + outcome, + secretExists: admin.__hasDocument(SECRET_PATH), + stateExists: admin.__hasDocument(STATE_PATH), + transactionDeletes: admin.__getTransactionDeletes(), + writeCount: admin.__getWrites().length, + }).toEqual({ + cancelCount: 1, + connectionExists: false, + logCallCounts: [0, 0, 0, 0, 0], + outcome: { + error: { + cause: undefined, + code: 'unavailable', + details: undefined, + message: FIXED_AUTHORIZATION_ERROR, + }, + kind: 'rejected', + }, + secretExists: false, + stateExists: false, + transactionDeletes: [STATE_PATH], + writeCount: 0, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(chunkRead).toHaveBeenCalledTimes(2); + expect(response.bodyUsed).toBe(true); + expectNoSideEffects(); + }); + + test('OAUTH-001A2K persists and returns an authorization reply at the exact byte ceiling', async () => { + const bytes = paddedJsonBytes(validExchangeResponse(), MAX_STRAVA_TOKEN_RESPONSE_BYTES); + const native = nativeChunkedResponse([bytes], { + headers: { 'Content-Length': String(MAX_STRAVA_TOKEN_RESPONSE_BYTES) }, + }); + fetchMock.mockResolvedValue(native.response); + + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); + + expect(result).toEqual({ ok: true, athleteId: 123456 }); + expect(bytes).toHaveLength(MAX_STRAVA_TOKEN_RESPONSE_BYTES); + expect(native.chunkRead).toHaveBeenCalledTimes(1); + expect(admin.__getWrites()).toEqual(expectedExchangeWrites()); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(Timestamp.now).toHaveBeenCalledTimes(3); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('OAUTH-001A2K keeps bounded invalid authorization schema on fixed internal', async () => { + const native = nativeJsonResponse({ access_token: 'synthetic-incomplete-token' }); + fetchMock.mockResolvedValue(native.response); + + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'internal', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(native.bodyRead).toHaveBeenCalledTimes(1); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(admin.__getWrites()).toEqual([]); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('OAUTH-001A2K does not let a plain successful mock bypass the native body reader', async () => { + const json = jest.fn().mockResolvedValue(validExchangeResponse()); + fetchMock.mockResolvedValue({ ok: true, json }); + + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'unavailable', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(json).not.toHaveBeenCalled(); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(admin.__getWrites()).toEqual([]); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('OAUTH-001A2K waits for stream end before authorization persistence or result', async () => { + const native = nativeChunkedResponse([ + Buffer.from(JSON.stringify(validExchangeResponse()), 'utf8'), + ], { stayOpen: true }); + fetchMock.mockResolvedValue(native.response); + let settled = false; + + const exchange = stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT) + .finally(() => { settled = true; }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(native.chunkRead).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(admin.__getWrites()).toEqual([]); + expect(Timestamp.now).not.toHaveBeenCalled(); + + native.close(); + await expect(exchange).resolves.toEqual({ ok: true, athleteId: 123456 }); + expect(admin.__getWrites()).toEqual(expectedExchangeWrites()); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + function expectedExchangeWrites() { return [ { @@ -921,23 +1505,10 @@ describe('Strava authorization exchange failure boundary', () => { } async function expectInvalidSuccessfulResponse(response) { - const json = mockExchangeResponse(response); - - const error = await captureFailure(() => stravaExchangeCode({ - code: CODE, - state: STATE, - }, CONTEXT)); - - expect(publicError(error)).toEqual({ - code: 'internal', - message: FIXED_AUTHORIZATION_ERROR, - details: undefined, - cause: undefined, - }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(json).toHaveBeenCalledTimes(1); - expect(admin.__hasDocument(STATE_PATH)).toBe(false); - expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(snapshotAuthorizationExchangeResponse(response)).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(admin.__hasDocument(STATE_PATH)).toBe(true); + expect(admin.__getTransactionDeletes()).toEqual([]); expectNoSideEffects(); } @@ -1618,19 +2189,22 @@ describe('Strava authorization exchange failure boundary', () => { }); test('accepts a genuine successful Node Response', async () => { - const json = jest.fn().mockResolvedValue(validExchangeResponse()); - const response = new Response(null, { status: 200 }); - Object.defineProperty(response, 'json', { + const native = nativeJsonResponse(validExchangeResponse()); + const json = jest.fn(() => { + throw new Error('native json override must not run'); + }); + Object.defineProperty(native.response, 'json', { configurable: true, value: json, }); - fetchMock.mockResolvedValue(response); + fetchMock.mockResolvedValue(native.response); const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); - expect(json).toHaveBeenCalledTimes(1); - expect(response.bodyUsed).toBe(false); + expect(json).not.toHaveBeenCalled(); + expect(native.bodyRead).toHaveBeenCalledTimes(1); + expect(native.response.bodyUsed).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); expect(admin.__getWrites()).toEqual(expectedExchangeWrites()); expect(admin.__hasDocument(STATE_PATH)).toBe(false); @@ -1665,10 +2239,9 @@ describe('Strava authorization exchange failure boundary', () => { }); test('turns malformed provider JSON into one fixed unavailable result', async () => { - const json = jest.fn().mockRejectedValue( - new Error('json-canary access_token=provider-secret-canary'), - ); - fetchMock.mockResolvedValue({ ok: true, json }); + const providerBytes = Buffer.from('{"access_token":"provider-secret-canary"', 'utf8'); + const native = nativeChunkedResponse([providerBytes]); + fetchMock.mockResolvedValue(native.response); const error = await captureFailure(() => stravaExchangeCode({ code: CODE, @@ -1683,7 +2256,7 @@ describe('Strava authorization exchange failure boundary', () => { }); expect(JSON.stringify(publicError(error))) .not.toMatch(/json-canary|provider-secret-canary/i); - expect(json).toHaveBeenCalledTimes(1); + expect(native.chunkRead).toHaveBeenCalledTimes(1); expectNoSideEffects(); }); @@ -1699,7 +2272,7 @@ describe('Strava authorization exchange failure boundary', () => { await expectInvalidSuccessfulResponse(response); }); - test('rejects a transparent root Proxy before validator-controlled reflection', async () => { + test('rejects a transparent root Proxy without a then lookup or reflection trap', async () => { const observedGetKeys = []; const secondThenFailure = new Error( 'second-then-canary access_token=provider-secret-canary', @@ -1729,33 +2302,21 @@ describe('Strava authorization exchange failure boundary', () => { await expectInvalidSuccessfulResponse(response); - expect(observedGetKeys).toEqual(['then']); + expect(observedGetKeys).toEqual([]); Object.values(reflectionTraps).forEach((trap) => expect(trap).not.toHaveBeenCalled()); }); - test('keeps a throwing root then trap inside the existing unavailable JSON boundary', async () => { + test('rejects a root Proxy with a throwing then trap without invoking it', async () => { const thenFailure = new Error('root-then-canary access_token=provider-secret-canary'); - const response = new Proxy(validExchangeResponse(), { - get: (_target, key) => { - if (key === 'then') throw thenFailure; - throw new Error('unexpected root Proxy read'); - }, + const get = jest.fn((_target, key) => { + if (key === 'then') throw thenFailure; + throw new Error('unexpected root Proxy read'); }); - mockExchangeResponse(response); + const response = new Proxy(validExchangeResponse(), { get }); - const error = await captureFailure(() => stravaExchangeCode({ - code: CODE, - state: STATE, - }, CONTEXT)); + await expectInvalidSuccessfulResponse(response); - expect(publicError(error)).toEqual({ - code: 'unavailable', - message: FIXED_AUTHORIZATION_ERROR, - details: undefined, - cause: undefined, - }); - expect(JSON.stringify(publicError(error))).not.toMatch(/root-then-canary|provider-secret/i); - expectNoSideEffects(); + expect(get).not.toHaveBeenCalled(); }); test.each([ @@ -1963,13 +2524,12 @@ describe('Strava authorization exchange failure boundary', () => { }); try { - mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); + const projected = snapshotAuthorizationExchangeResponse(response); - expect(result).toEqual({ ok: true, athleteId: 123456 }); - expect(admin.__getWrites()[1].data.firstName).toBeNull(); - expect(Timestamp.now).toHaveBeenCalledTimes(3); - consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + expect(projected).toMatchObject({ athlete: { firstName: null } }); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected.athlete)).toBe(true); + expectNoSideEffects(); } finally { delete Object.prototype.firstname; } @@ -2078,14 +2638,16 @@ describe('Strava authorization exchange failure boundary', () => { const response = validExchangeResponse(); Object.freeze(response.athlete); Object.freeze(response); - mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); + const projected = snapshotAuthorizationExchangeResponse(response); - expect(result).toEqual({ ok: true, athleteId: 123456 }); - expect(admin.__getWrites()).toHaveLength(2); - expect(Timestamp.now).toHaveBeenCalledTimes(3); - consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + expect(projected).toMatchObject({ + accessToken: 'access_token_test', + athlete: { firstName: 'Synthetic', id: 123456 }, + }); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected.athlete)).toBe(true); + expectNoSideEffects(); }); test('accepts non-enumerable selected own data descriptors', async () => { @@ -2102,15 +2664,16 @@ describe('Strava authorization exchange failure boundary', () => { value: response.athlete.firstname, writable: false, }); - mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); + const projected = snapshotAuthorizationExchangeResponse(response); - expect(result).toEqual({ ok: true, athleteId: 123456 }); - expect(admin.__getWrites()[0].data.access_token).toBe('access_token_test'); - expect(admin.__getWrites()[1].data.firstName).toBe('Synthetic'); - expect(Timestamp.now).toHaveBeenCalledTimes(3); - consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + expect(projected).toMatchObject({ + accessToken: 'access_token_test', + athlete: { firstName: 'Synthetic' }, + }); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected.athlete)).toBe(true); + expectNoSideEffects(); }); test('ignores unknown hostile fields without enumeration or access', async () => { @@ -2131,15 +2694,17 @@ describe('Strava authorization exchange failure boundary', () => { enumerable: true, get: unknownSymbolGetter, }); - mockExchangeResponse(response); + const projected = snapshotAuthorizationExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); - - expect(result).toEqual({ ok: true, athleteId: 123456 }); + expect(projected).toMatchObject({ + accessToken: 'access_token_test', + athlete: { id: 123456 }, + }); expect(unknownGetter).not.toHaveBeenCalled(); expect(unknownSymbolGetter).not.toHaveBeenCalled(); - expect(admin.__getWrites()).toHaveLength(2); - consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected.athlete)).toBe(true); + expectNoSideEffects(); }); }); @@ -2624,9 +3189,9 @@ describe('Strava token refresh failure boundary', () => { } function mockRefreshResponse(response) { - const json = jest.fn().mockResolvedValue(response); - fetchMock.mockResolvedValueOnce({ ok: true, json }); - return json; + const native = nativeJsonResponse(response); + fetchMock.mockResolvedValueOnce(native.response); + return native.bodyRead; } function mockValidRefreshFlow(response = validRefreshResponse()) { @@ -2664,33 +3229,9 @@ describe('Strava token refresh failure boundary', () => { } async function expectInvalidSuccessfulRefresh(response) { - seedExpiredConnection(); - const json = mockRefreshResponse(response); - - const error = await captureFailure(() => stravaFetchStats({}, CONTEXT)); - - expect(publicError(error)).toEqual({ - code: 'internal', - message: FIXED_REFRESH_ERROR, - details: undefined, - cause: undefined, - }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - 'https://www.strava.com/api/v3/oauth/token', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_id: 'strava_client_test', - client_secret: 'strava_secret_test', - refresh_token: 'synthetic_refresh_token_test', - grant_type: 'refresh_token', - }), - }, - ); - expect(json).toHaveBeenCalledTimes(1); - expect(admin.__getReads()).toEqual([CONNECTION_PATH, SECRET_PATH]); + expect(snapshotRefreshTokenResponse(response)).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(admin.__getReads()).toEqual([]); expectNoWritesOrLogs(); } @@ -3165,12 +3706,11 @@ describe('Strava token refresh failure boundary', () => { scope: 'read', }); seedExpiredConnection(); - const json = jest.fn().mockImplementation(async () => { - admin.__setDocument(SECRET_PATH, newerSecret); - return validRefreshResponse(); + const native = nativeJsonResponse(validRefreshResponse(), { + onBodyRead: () => admin.__setDocument(SECRET_PATH, newerSecret), }); fetchMock - .mockResolvedValueOnce({ ok: true, json }) + .mockResolvedValueOnce(native.response) .mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue([]), @@ -3187,7 +3727,7 @@ describe('Strava token refresh failure boundary', () => { const rejection = await captureFailure(() => stravaFetchStats({}, CONTEXT)); expectRefusedMutation(rejection); - expect(json).toHaveBeenCalledTimes(1); + expect(native.bodyRead).toHaveBeenCalledTimes(1); expect(admin.__getDocument(SECRET_PATH)).toBe(newerSecret); }); @@ -3202,31 +3742,29 @@ describe('Strava token refresh failure boundary', () => { ) => { const changedSecret = Object.freeze({ ...EXPIRED_SECRET, [field]: value }); seedExpiredConnection(); - const json = jest.fn().mockImplementation(async () => { - admin.__setDocument(SECRET_PATH, changedSecret); - return validRefreshResponse(); + const native = nativeJsonResponse(validRefreshResponse(), { + onBodyRead: () => admin.__setDocument(SECRET_PATH, changedSecret), }); - fetchMock.mockResolvedValueOnce({ ok: true, json }); + fetchMock.mockResolvedValueOnce(native.response); const rejection = await captureFailure(() => stravaFetchStats({}, CONTEXT)); expectRefusedMutation(rejection); - expect(json).toHaveBeenCalledTimes(1); + expect(native.bodyRead).toHaveBeenCalledTimes(1); expect(admin.__getDocument(SECRET_PATH)).toBe(changedSecret); }); test('refuses when the stored secret is deleted during provider refresh', async () => { seedExpiredConnection(); - const json = jest.fn().mockImplementation(async () => { - admin.__removeDocument(SECRET_PATH); - return validRefreshResponse(); + const native = nativeJsonResponse(validRefreshResponse(), { + onBodyRead: () => admin.__removeDocument(SECRET_PATH), }); - fetchMock.mockResolvedValueOnce({ ok: true, json }); + fetchMock.mockResolvedValueOnce(native.response); const rejection = await captureFailure(() => stravaFetchStats({}, CONTEXT)); expectRefusedMutation(rejection); - expect(json).toHaveBeenCalledTimes(1); + expect(native.bodyRead).toHaveBeenCalledTimes(1); expect(admin.__hasDocument(SECRET_PATH)).toBe(false); }); @@ -3241,16 +3779,15 @@ describe('Strava token refresh failure boundary', () => { get: unknownGetter, }); seedExpiredConnection(); - const json = jest.fn().mockImplementation(async () => { - admin.__setDocument(SECRET_PATH, malformedSecret); - return validRefreshResponse(); + const native = nativeJsonResponse(validRefreshResponse(), { + onBodyRead: () => admin.__setDocument(SECRET_PATH, malformedSecret), }); - fetchMock.mockResolvedValueOnce({ ok: true, json }); + fetchMock.mockResolvedValueOnce(native.response); const rejection = await captureFailure(() => stravaFetchStats({}, CONTEXT)); expectRefusedMutation(rejection); - expect(json).toHaveBeenCalledTimes(1); + expect(native.bodyRead).toHaveBeenCalledTimes(1); expect(unknownGetter).not.toHaveBeenCalled(); expect(admin.__getDocument(SECRET_PATH)).toBe(malformedSecret); }); @@ -3410,16 +3947,15 @@ describe('Strava token refresh failure boundary', () => { test('maps a secret-reference failure after refresh validation to one fixed result', async () => { const { failure, probes } = hostilePersistenceFailure(); seedExpiredConnection(); - const json = jest.fn().mockImplementation(async () => { - admin.__setDocumentReferenceFailure(SECRET_PATH, failure); - return validRefreshResponse(); + const native = nativeJsonResponse(validRefreshResponse(), { + onBodyRead: () => admin.__setDocumentReferenceFailure(SECRET_PATH, failure), }); - fetchMock.mockResolvedValueOnce({ ok: true, json }); + fetchMock.mockResolvedValueOnce(native.response); const rejection = await captureFailure(() => stravaFetchStats({}, CONTEXT)); expectFixedPersistenceFailure(rejection, probes); - expect(json).toHaveBeenCalledTimes(1); + expect(native.bodyRead).toHaveBeenCalledTimes(1); expect(Timestamp.now).not.toHaveBeenCalled(); expect(admin.__getTransactionRunAttempts()).toBe(0); expect(admin.__getTransactionReads()).toEqual([]); @@ -3639,10 +4175,9 @@ describe('Strava token refresh failure boundary', () => { test('turns malformed refresh JSON into one fixed unavailable result', async () => { seedExpiredConnection(); - const json = jest.fn().mockRejectedValue( - new Error('json-canary access_token=provider-secret-canary'), - ); - fetchMock.mockResolvedValue({ ok: true, json }); + const providerBytes = Buffer.from('{"access_token":"provider-secret-canary"', 'utf8'); + const native = nativeChunkedResponse([providerBytes]); + fetchMock.mockResolvedValue(native.response); const error = await captureFailure(() => stravaFetchStats({}, CONTEXT)); @@ -3654,12 +4189,67 @@ describe('Strava token refresh failure boundary', () => { }); expect(JSON.stringify(publicError(error))) .not.toMatch(/json-canary|provider-secret-canary/i); - expect(json).toHaveBeenCalledTimes(1); + expect(native.chunkRead).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1); expect(admin.__getReads()).toEqual([CONNECTION_PATH, SECRET_PATH]); expectNoWritesOrLogs(); }); + test('OAUTH-001A2K rejects refresh byte overflow before CAS or downstream bearer use', async () => { + seedExpiredConnection(); + const bytes = paddedJsonBytes( + validRefreshResponse(), + MAX_STRAVA_TOKEN_RESPONSE_BYTES + 1, + ); + const native = nativeChunkedResponse([ + bytes.subarray(0, MAX_STRAVA_TOKEN_RESPONSE_BYTES), + bytes.subarray(MAX_STRAVA_TOKEN_RESPONSE_BYTES), + ]); + fetchMock.mockResolvedValue(native.response); + + const error = await captureFailure(() => stravaFetchStats({}, CONTEXT)); + await Promise.resolve(); + + expect(publicError(error)).toEqual({ + code: 'unavailable', + message: FIXED_REFRESH_ERROR, + details: undefined, + cause: undefined, + }); + expect(native.chunkRead).toHaveBeenCalledTimes(2); + expect(native.cancel).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(admin.__getReads()).toEqual([CONNECTION_PATH, SECRET_PATH]); + expect(admin.__getTransactionRunAttempts()).toBe(0); + expect(admin.__getTransactionSetAttempts()).toEqual([]); + expect(admin.__getWrites()).toEqual([]); + expect(admin.__getDocument(SECRET_PATH)).toBe(EXPIRED_SECRET); + expect(Timestamp.now).not.toHaveBeenCalled(); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('OAUTH-001A2K keeps bounded invalid refresh schema on fixed internal', async () => { + seedExpiredConnection(); + const native = nativeJsonResponse({ access_token: 'synthetic-incomplete-token' }); + fetchMock.mockResolvedValue(native.response); + + const error = await captureFailure(() => stravaFetchStats({}, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'internal', + message: FIXED_REFRESH_ERROR, + details: undefined, + cause: undefined, + }); + expect(native.bodyRead).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(admin.__getTransactionRunAttempts()).toBe(0); + expect(admin.__getWrites()).toEqual([]); + expect(admin.__getDocument(SECRET_PATH)).toBe(EXPIRED_SECRET); + expect(Timestamp.now).not.toHaveBeenCalled(); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + describe('stored token validation before refresh or bearer use', () => { class StoredSecretClass { constructor() { @@ -4105,7 +4695,7 @@ describe('Strava token refresh failure boundary', () => { await expectInvalidSuccessfulRefresh(response); }); - test('rejects a transparent root Proxy after exactly one promise then lookup', async () => { + test('rejects a transparent root Proxy without a then lookup or reflection trap', async () => { const observedGetKeys = []; const secondThenFailure = new Error( 'second-then-canary access_token=provider-secret-canary', @@ -4135,36 +4725,22 @@ describe('Strava token refresh failure boundary', () => { await expectInvalidSuccessfulRefresh(response); - expect(observedGetKeys).toEqual(['then']); + expect(observedGetKeys).toEqual([]); Object.values(reflectionTraps).forEach((trap) => expect(trap).not.toHaveBeenCalled()); }); - test('keeps a throwing first root then lookup inside the unavailable JSON boundary', async () => { - seedExpiredConnection(); - const response = new Proxy(validRefreshResponse(), { - get: (_target, key) => { - if (key === 'then') { - throw new Error('root-then-canary refresh_token=provider-secret-canary'); - } - throw new Error('unexpected root Proxy read'); - }, + test('rejects a root Proxy with a throwing then trap without invoking it', async () => { + const get = jest.fn((_target, key) => { + if (key === 'then') { + throw new Error('root-then-canary refresh_token=provider-secret-canary'); + } + throw new Error('unexpected root Proxy read'); }); - const json = mockRefreshResponse(response); + const response = new Proxy(validRefreshResponse(), { get }); - const error = await captureFailure(() => stravaFetchStats({}, CONTEXT)); + await expectInvalidSuccessfulRefresh(response); - expect(publicError(error)).toEqual({ - code: 'unavailable', - message: FIXED_REFRESH_ERROR, - details: undefined, - cause: undefined, - }); - expect(JSON.stringify(publicError(error))) - .not.toMatch(/root-then-canary|provider-secret/i); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(json).toHaveBeenCalledTimes(1); - expect(admin.__getReads()).toEqual([CONNECTION_PATH, SECRET_PATH]); - expectNoWritesOrLogs(); + expect(get).not.toHaveBeenCalled(); }); test.each([ @@ -4319,17 +4895,18 @@ describe('Strava token refresh failure boundary', () => { enumerable: true, get: unknownSymbolGetter, }); - seedExpiredConnection(); - mockValidRefreshFlow(response); - await stravaFetchStats({}, CONTEXT); + const projected = snapshotRefreshTokenResponse(response); + expect(projected).toEqual({ + accessToken: 'refreshed_access_token_test', + refreshToken: 'refreshed_refresh_token_test', + expiresAt: 1_900_000_000, + }); unknownGetters.forEach(({ getter }) => expect(getter).not.toHaveBeenCalled()); expect(unknownSymbolGetter).not.toHaveBeenCalled(); - expect(admin.__getWrites()).toHaveLength(1); - expect(fetchMock).toHaveBeenCalledTimes(3); - expect(Timestamp.now).toHaveBeenCalledTimes(1); - consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + expect(Object.isFrozen(projected)).toBe(true); + expectNoWritesOrLogs(); }); test.each([ @@ -4371,19 +4948,16 @@ describe('Strava token refresh failure boundary', () => { writable: false, }); Object.freeze(response); - seedExpiredConnection(); - mockValidRefreshFlow(response); - await stravaFetchStats({}, CONTEXT); + const projected = snapshotRefreshTokenResponse(response); - expect(admin.__getWrites()[0].data).toMatchObject({ - access_token: 'refreshed_access_token_test', - refresh_token: 'refreshed_refresh_token_test', - expires_at: 1_900_000_000, + expect(projected).toEqual({ + accessToken: 'refreshed_access_token_test', + refreshToken: 'refreshed_refresh_token_test', + expiresAt: 1_900_000_000, }); - expect(fetchMock).toHaveBeenCalledTimes(3); - expect(Timestamp.now).toHaveBeenCalledTimes(1); - consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + expect(Object.isFrozen(projected)).toBe(true); + expectNoWritesOrLogs(); }); test('persists an unchanged returned refresh token as the latest token', async () => { @@ -4464,15 +5038,13 @@ describe('Strava token refresh failure boundary', () => { test('preserves successful refresh, secret update, and downstream stats result', async () => { seedExpiredConnection(); + const nativeRefresh = nativeJsonResponse({ + access_token: 'refreshed_access_token_test', + refresh_token: 'refreshed_refresh_token_test', + expires_at: 1_900_000_000, + }); fetchMock - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ - access_token: 'refreshed_access_token_test', - refresh_token: 'refreshed_refresh_token_test', - expires_at: 1_900_000_000, - }), - }) + .mockResolvedValueOnce(nativeRefresh.response) .mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValue([{