Security remediation — 13 September 2026
This change addresses TS-01 through TS-07 in the assessment. No paid OCR/model calls were made during local verification. Release status is tracked by the Main Pipeline run for the merged commit.
Fixes
| Finding | Implemented control |
|---|---|
| TS-01: PDF and processing amplification | Read at most 10 MiB plus one overflow byte; verify the actual SHA-256 and image signature/PDF page count; reject encrypted, invalid, or over-three-page PDFs before Textract. Reserve daily processing starts before parsing and page allowances before OCR. Limit model input to 24,000 OCR characters and output to 2,048 tokens. Disable automatic paid-provider retries. |
| TS-02: signup-credit recycling | Atomically write a purpose-separated HMAC fingerprint with the first signup grant. It has neither a UserId index entry nor a TTL, so deletion cannot erase eligibility. Recreated accounts receive zero signup credits. Deleting legacy accounts first records fingerprints for their identity mappings before removing those mappings. |
| TS-03: unauthenticated reads and request amplification | Require a valid signed invitation for every public group/expense read and join request. Reject forged capabilities before group reads. Rate-limit public source IPs and authenticated subjects; use conditional increments, fail closed on limiter failures, and cache denials in warm workers. Add API Gateway route throttles and DynamoDB table/index throughput ceilings. |
| TS-04: forced membership and deletion veto | Reject direct USER participant additions. Joining an account requires that user's signed-link request and owner approval. Self-departure/account deletion detaches identity into a guest record, preserving expense and settlement references. Sole owners of shared groups must still transfer ownership first. |
| TS-05: unlimited upload storage | Require available scan credit and reserve both upload count and bytes against per-user and global daily allowances before presigning. Bind PUTs to length and If-None-Match: *, with a signed checksum for hash-based uploads; uploaded objects cannot be overwritten through these tickets. |
| TS-06: unbounded fan-out and payloads | Unchanged profile names cause no writes. Weight profile/dashboard requests more heavily; cap creation, rosters, balance reads, comments, items, payments, divisions, and request-body size. Snapshot a bounded membership set before profile fan-out. Oversized history errors do not masquerade as zero balances. |
| TS-07: replay after job TTL | A previously charged attempt whose job is gone returns 410 and cannot recreate or enqueue work. Duplicate queue deliveries require an atomic processing lease; at most two worker attempts are allowed per charged run. |
Recurring templates now record their actual creator, and the worker pauses templates when that participant loses write access. Payers no longer confer ownership. Legacy templates with no trustworthy creator are paused rather than automatically attributed to a payer.
Electron now uses a sandboxed preload, rejects non-app navigation and untrusted auth IPC senders, opens only HTTPS external links, validates authentication destinations/deep links, and avoids plaintext token persistence when OS encryption is unavailable.
Invitation behavior
Owners retrieve signed links through GET /groups/{groupId}/invite/settings. The token contains the group ID, purpose, revision and expiry, authenticated with HMAC-SHA256. It expires after 90 days. Enabling or replacing a link creates a new random revision; disabling or replacing invalidates previously saved links through a consistent server-side revision check.
A token grants the read-only invite view, whether the visitor is signed in or signed out. Joining as a participant still requires consent and owner approval. Web/desktop use versioned localStorage keys; native mobile uses SecureStore. Returning to the group's invite route restores its saved capability. API calls carry the token in x-tailspend-invite, not API query strings. Web referrer policy is no-referrer, and API responses use no-store.
These are bearer links: anyone who receives a forwarded copy can use it until expiry or revocation. Tokens do not prove the recipient's identity. Stored tokens remain exposed to code running in the same web origin; persistent storage does not replace XSS defenses. Revocation blocks future reads but cannot retract data already viewed or copied.
Initial operating limits
Limits are deliberately conservative and can be changed in the reviewed configuration/code as usage grows.
| Resource | Per user | Per service/stage |
|---|---|---|
| Upload tickets per UTC day | 50 | 2,000 |
| Declared upload bytes per UTC day | 150 MiB | 10 GiB |
| Worker processing starts per UTC day, including retries/invalid documents | 50 | 1,000 |
| OCR pages per UTC day | 150 | 3,000 |
| Request allowance per minute | 120 weighted units | Gateway route limits below |
Each receipt is at most 10 MiB and three pages. Processing starts are reserved before PDF parsing, so malformed inputs also consume an allowance. Failed work does not replenish an allowance. Daily quota rows expire after three days; persistent creation counters do not expire.
Creation budgets: 50 membership creations per account; 49 additional participant creations per group (plus its initial owner); 1,000 expenses, 1,000 settlements, and 50 recurring templates per group; 100 comments per expense. Deletion does not restore creation allowance. Balance aggregation is capped at five pages of up to 200 expense/settlement rows; roster and name fan-out reads are also bounded. Requests above 256 KiB are rejected.
Gateway defaults are 30 requests/second with a 60-request burst per route, reduced to 2/5 for upload/job creation and 5/10 for public reads and ad callbacks. Tables and GSIs have 500 read-unit / 200 write-unit ceilings. These controls bound application work; they are not an absolute AWS dollar cap, and rejected traffic, requests, logs, storage, and other infrastructure can still incur charges.
Rollout requirements
- Deploy the API/infra changes and updated clients together. Unsigned old invite URLs will stop working; owners must enable/regenerate signed links. Existing participants retain their membership access. Previously issued upload URLs can remain valid until their existing expiry; new uploads use immutable PUTs.
- Keep
/tailspend/{stage}/cursor-signing-secretavailable as a high-entropy secret of at least 32 bytes. Auth provisioning now also needs read permission. Signing uses purpose separation for cursors, invites and eligibility fingerprints. Do not casually rotate this secret: doing so invalidates links/cursors and breaks matching against old signup fingerprints. A rotation needs an explicit eligibility-preserving migration/key-retention plan. - Review legacy groups before rollout. New creation counters begin when this version starts recording operations; they are creation budgets, not a retroactive inventory count. Larger existing rosters/histories can exceed the new read budgets and require an explicit migration or revised bounded allowance. No production records were edited here.
- Legacy recurring templates require owner recreation with a recorded creator before they can resume automatic processing. Owner-count handling supports missing legacy counters while preserving the last-owner guard.
- Release updated native clients so their immutable upload header and signed-link handling match the API. Server controls remain authoritative if an old client is used.
Verification and dependency status
Service tests cover token tampering, cross-group reuse, expiry, revocation, missing tokens, PDF limits, allowance failures, upload credit checks, TTL replay, duplicate worker deliveries, departure with history, creator revocation, unchanged-name suppression, and handler rate/body limits. Client tests exercise persistence and capability headers across preview and authenticated join requests. Provisioning tests verify both atomic first grants and zero-credit recreation.
Passing suites include 941 service tests, 148 shared UI tests, 34 infrastructure tests, 17 desktop Vitest tests, and 10 mobile Node tests. Local verification also includes TypeScript checks, lint, a clean-install dry run, OpenAPI/client generation, desktop builds and launch with sandbox: true, contextIsolation: true, nodeIntegration: false, and a working preload bridge. An iOS production export succeeded. Its source map contains the patched decoder adapter and decode-uri-component-safe, and excludes the vulnerable decoder implementation. No live AWS integration/deployment or native device sign-in was performed.
Compatible dependency updates are included. Expo SDK 56 still declares the vulnerable CommonJS URL-decoder chain; Metro redirects it through a compatibility adapter to upstream decode-uri-component 0.5.0, retaining Expo's module contract. See the upstream advisory. Automated audit output can still flag this installed but replaced chain.
The Metro image-size dependency has advisories with no available patched image-size release in the audit: ICNS parser and JXL/HEIF parsers. These concern build-time asset parsing; receipt uploads are processed by the services pipeline, not Metro. Do not treat the dependency audit as clean. The final audit reports seven entries (four high, three moderate, zero critical), including transitive parent packages. Remaining entries and exact versions are captured in the post-remediation audit. All installed js-yaml copies are updated to 4.3.2.
The original reproduce-2026-09-12.mjs is historical evidence for the assessment's baseline commit, not a passing regression script for this fixed tree. Use the current service and client test suites to verify these controls.
Release preflight
On 13 September, a bounded read-only inventory completed for all three stages. Production had 39 groups, at most four participants and 33 expenses per group, and at most two memberships per account; no recurring templates were found. Dev and beta were also below the new read limits. No legacy data migration is required for these observed sizes. Production has 39 enabled unsigned invite records; owners must replace their links after release. All three signing parameters exist and contain 64 bytes; their values were not logged or changed.
The change is rebased onto the current async Lambda scan dispatcher, preserving the removal of idle SQS polling. Worker leases and attempt limits apply to direct invocation and retained legacy SQS messages. Live integration tests now send signed invite capabilities and immutable upload headers, check missing/forged/revoked invitations, and back off once on explicit 429 responses. Persistent failures still fail the release gate.
Follow-up issues
Resolved on 14 September 2026, in the same release:
- #533 — Expo/Metro dependencies. No patched
image-sizeexists (the advisories cover<= 2.0.2, the latest published version), so the reachable parsers are now unreachable instead:metro.config.jsdropsheic,heif,avif,jxlandicnsfromassetExts. The Expo decoder adapter stays — the audit's suggested "fix" is an olderexpo-routermajor than SDK 56 ships. Both exceptions, with exit conditions, are in dependency-exceptions.md; fresh audit in npm-audit-after-2026-09-14.json (7 entries, unchanged count). - #534 — Budgets and alerts. Approved ceiling is USD 20/month, account-wide.
TailSpend-{stage}-Monitoringadds an SNS topic, a budget with actual and forecast notifications, a cost-anomaly monitor, and alarms for quota denials, failed and abandoned scans, DynamoDB throttling and worker throttling. Quota exhaustion still blocks until 00:00 UTC with no self-service reset. Policy and response steps: cost-controls-runbook.md. - #535 — Eligibility retention and key rotation. Approved retention is
24 months from the last write, now enforced by a TTL on the Identities
table. Fingerprints moved off the cursor secret onto a dedicated, versioned
key (
/tailspend/{stage}/signup-fingerprint-secret, generated on first deploy) so a cursor rotation can no longer re-grant spent signup credits. Pre-split markers are still matched and are promoted on next use. User-facing wording is on the privacy page and the delete-account confirmation, and does not describe the marker as anonymous. - #536 — Invite rollout. Sharing stays possession-based: a signature proves
the link came from us, not who holds it, and the UI now says so along with the
concrete expiry date.
GET /invite/settingsreturnsneedsReplacementso the 39 production groups whose invite predates signing get an explicit "your old link no longer works" recovery prompt instead of a bare "disabled". - #538 — Interrupted scan recovery. A duplicate delivery against a live lease
is now a no-op rather than a thrown error,
maxEventAgeis aligned to the 16-minute lease, paid model output is persisted before the completion write so a retried write repeats no Textract/Bedrock work, and a five-minute reconciliation tick redispatches or terminally abandons jobs whose lease has lapsed. The attempt stays charged; refunds are operator-initiated.
Still open:
- #537: Installed-device verification — needs a human with installed iOS/Android apps, a Mac and a Windows machine. Nothing further can be done in code.