TailSpend Docs

TailSpend security and cost-abuse assessment

Date: 12 September 2026. Reviewed commit: 63ac66028d11c6b73771b8b6de812c7da443c6f4.

Seven application findings: four high, three medium. The most urgent combination is per-document scan charging without a PDF page limit, plus repeatable signup credits. Separately, public requests and authenticated requests rejected by the rate limiter can still generate AWS costs. An unrelated group owner can also prevent another user from deleting their account.

This is a source and configuration assessment with offline reproductions. It covers API authorization, identity provisioning/deletion, credit accounting, uploads, OCR/model work, public invitations, data-access amplification, and a limited client/dependency review. Production deployment state, AWS account quotas, billing alarms, traffic, and historic compromise were not inspected. No live uploads, paid model calls, account changes, load tests, or deployments were performed. Infrastructure conclusions describe what this repository configures; externally configured controls could reduce exposure.

Severity reflects practical impact and attacker prerequisites, not a formal CVSS score. No demonstrated admin escalation, arbitrary private-group read, or remote code execution was found in the reviewed paths. This is not an exhaustive penetration test.

ID Severity Finding Prerequisite
TS-01 High One scan credit can buy thousands of PDF pages Account, group membership, one credit
TS-02 High Delete/recreate restores signup credits and resets account quotas One reusable social identity
TS-03 High Public traffic and rejected authenticated requests still drive backend cost None for public routes; valid token for limiter writes
TS-04 High Forced membership can block another user's account deletion Attacker-owned group and victim's user UUID
TS-05 Medium Uploads have no cumulative byte/object allowance Account; no scan credits required
TS-06 Medium Cheap API calls amplify into large database reads/writes Account with accumulated groups/data
TS-07 Medium Expired scan jobs can be recreated using an already-spent attempt Old paid attempt, expired job, same input, at least one remaining credit

TS-01 — PDF page count is not metered or bounded before paid OCR

Evidence: Textract OCR sends any .pdf key to StartDocumentTextDetection, follows every result page, and concatenates all OCR text. There is no PDF parser/page-count preflight. Upload validation limits bytes to 15 MiB, which does not bound document pages. Job submission reserves the fixed SCAN_COST = 1; the daily counter permits 50 attempts, not 50 pages.

Abuse: Submit compact PDFs containing many pages, changing the document bytes between submissions to avoid content deduplication. An account with 50 credits can submit 50 large documents within its intended daily limit. This does not require forging a signature or bypassing credit atomicity.

AWS permits up to 3,000 pages in an asynchronous PDF. At AWS's published Oregon first-tier example rate of $0.0015 per detected page, a 3,000-page document represents $4.50 of OCR, or $225 for 50 such documents, before other services. These are illustrative calculations, not measured charges or a quote for TailSpend's region/tier. The three signup credits could represent $13.50 per account cycle under the same assumptions. AWS document limits, AWS pricing.

The worker's ten-consumer cap limits throughput, not cumulative spending. PDF polling also occupies a 1 GiB Lambda while waiting, and the full OCR text enters the Bedrock prompt without an application input-length limit. max_tokens: 2048 bounds model output, not OCR pages or prompt size. A model rejection occurs after OCR work has already started.

Validation: An offline SDK mock returned 3,000-page metadata and paginated text; the actual OCR class accepted it and fetched the next page. This establishes the missing check, not acceptance/price/latency of a real 3,000-page fixture by AWS.

Fix: Immediately impose a small server-verified page cap before Textract submission, or temporarily limit scanning to single-page images. Reserve credits and daily/global allowances against verified pages. Bound extracted characters/model input. Use completion notifications for asynchronous OCR rather than keeping Lambda polling. Add a global paid-work allowance and a kill switch; budget notifications alone do not stop work.

TS-02 — Account deletion erases signup-grant eligibility

Evidence: Provisioning treats a missing upstream identity as a new user and grants three credits. User deletion removes all user rows, scan counters, ledger/idempotency markers, and upstream identity mappings. DELETE /users/me also deletes the Cognito user so a later social sign-in can provision again.

Abuse: Use signup credits in a solo group without saving expenses; delete the account; sign in with the same Google/Apple identity; receive another grant. Scanning does not require saving an expense, and solo groups can be deleted during account cleanup. A fresh TailSpend user ID resets the daily scan allowance; a new Cognito subject also gets a new request-limit key. Social-only signup prevents arbitrary password registrations but does not prevent this cycle.

Impact: Renewable free paid-service usage without earning ad credits. Combining this with TS-01 makes the grant materially more expensive. More generally, quotas attached only to disposable account IDs do not contain repeat registration.

Validation: Using actual provisioning and user-data deletion code against an in-memory DynamoDB mock, the same Google subject received three credits, had its balance consumed and rows deleted, then received a new user ID and another three credits. The browser/Cognito round trip was not executed.

Fix: Separate signup eligibility from disposable account state. Use a minimal, retention-bounded keyed identifier for grant eligibility where appropriate, with a cooldown and global grant budget. Alternatively remove the signup grant and require a verified reward/payment. Do not retain complete deleted profiles merely to solve abuse. Carry paid-service abuse limits across account recreation.

TS-03 — The request limiter does not contain infrastructure spending

Evidence: withHandler only limits JWT routes. Public invitation reads and AdMob callbacks skip it. The limiter performs a DynamoDB UpdateItem on every authenticated request before deciding to return 429; it silently allows requests when the store errors. ServiceStack does not configure application-specific stage/route throttles or an edge abuse control. Its stage customization configures access logging. DynamoDB uses on-demand billing without application-specific throughput ceilings here.

Abuse and impact:

AWS account/service quotas still apply. This is not literally unlimited throughput. AWS documents HTTP API throttles as best-effort targets, and account defaults are shared across APIs. AWS HTTP API throttling.

Validation: The actual limiter performed three writes for one accepted plus two rejected requests. A simulated store error let a request proceed. Public-route exemptions and missing infrastructure settings were verified in source; traffic/cost was not measured.

Fix: Set conservative stage and route burst/rate targets before Lambda invocation. Put public routes behind an appropriate edge rate/bot control and ensure direct-origin access cannot bypass it. Add IP/resource-key quotas and bounded responses. Fail closed for expensive operations when their quota system is unavailable. Configure concurrency/capacity limits with availability tradeoffs, global work allowances, spend/error alarms, and an operational stop mechanism. Use request limits as one layer, not a monetary guarantee.

TS-04 — An owner can force membership and hold account deletion hostage

Evidence: Add participant checks that the caller owns their own group, then accepts any existing target user UUID. It never checks the target's consent, a pending invitation, or a mutual relationship. Participant removal rejects any participant referenced by an expense or settlement. Account deletion applies that rejection before deleting user data. The participant delete endpoint requires OWNER, including for self-removal.

Abuse: An owner adds a known victim as VIEWER to an attacker-controlled group, then creates an expense referencing the victim. The victim cannot remove the expense or leave using the owner-only endpoint, and DELETE /users/me returns 409. The UUID can be learned from a group where the two users already share membership; it need not be guessed.

Impact: Targeted harassment, unsolicited balances, and denial of account deletion. It also lets an attacker inflate another user's dashboard work. This does not establish a real monetary debt or move money.

Validation: The actual add-handler accepted an unrelated target. The actual account service and participant-removal check then rejected victim deletion when the mocked repository reported an attacker-created expense reference. The check did not write victim data or use real users.

Fix: Require target acceptance before creating an active USER membership; use GUEST placeholders until then. Allow a user to leave/block an unwanted group. Account deletion should detach/anonymize their identity while preserving historical accounting rows, rather than giving another group owner a veto. Add a regression scenario with two unrelated accounts and an attacker-created expense.

TS-05 — Upload allowance is disconnected from scanning allowance

Evidence: POST /scans/uploads resolves identity and presigns a PUT, without checking credit balance, membership, daily upload bytes, or outstanding uploads. Hashes are optional; storage can generate a fresh random object key each time. Signed content length does prevent oversized individual uploads. MediaStack expires objects after one day and disables versioning, but has no per-user accumulation control.

Abuse: A zero-credit account requests fresh tickets and uploads objects that are never scanned. At the configured 120 requests/minute and 15 MiB/object, the nominal allowance is 172,800 objects / about 2.47 TiB newly uploaded per day per account, assuming sufficient client bandwidth, valid tokens, and service capacity. This is an upload-volume calculation, not a storage-bill estimate. Lifecycle expiry reduces retention; it does not eliminate PUT/storage costs. Presigned URLs can also be replayed within their lifetime, producing further PUT requests to the same key without creating another ticket.

Validation: The actual upload handler issued tickets for maximum-size declarations with only identity and storage dependencies. No credit or membership read occurred. S3 PUTs were not performed.

Fix: Atomically reserve a per-user daily byte/object allowance before signing, cap outstanding tickets, and link each upload to an authorized scan attempt. Release/expire reservations safely. Use immutable object creation where practical and bind content hashes. Preserve the existing signed size/checksum protections. Add independent upload-rate targets at the API layer.

TS-06 — Allowed calls can amplify into expensive database fan-out

Evidence: Profile update always invokes name synchronization when a name is supplied, even if unchanged. renameUserParticipants walks every membership page and updates each row sequentially, without a per-request work cap. Group creation has no per-user count quota.

Related multipliers are the dashboard, which recalculates balances from expense/settlement history across the returned groups, and the public invite, which loads the complete roster. Expense aggregation has a real 200-page cap; it is not unbounded, but still allows substantial work per request. Individual item labels and expense arrays also lack explicit maximum lengths/counts in the API schema.

Abuse: Build many groups, then repeatedly submit the same profile name. A single allowed request can produce hundreds or thousands of writes. Populate history and repeatedly request dashboard/balances to amplify reads. Large groups can hit Lambda duration/response limits and hurt legitimate members. Timeouts and service quotas constrain realized work but do not make it cheap.

Validation: Two identical profile updates each invoked the actual service's fan-out path. The mock reported 1,000 memberships; this demonstrates repeated dispatch, not 2,000 measured database writes. The repository loop and dashboard aggregation were reviewed directly.

Fix: Skip unchanged names, cap groups/members/records and payload sizes, and assign heavier rate weights to expensive routes. Maintain balance projections. Move large name updates into bounded, deduplicated batches, or resolve current profile names on read. Paginate public rosters/comment threads rather than composing unlimited collections.

TS-07 — Scan job TTL outlives the meaning of the credit replay marker

Evidence: Scan jobs expire after 24 hours; SPEND markers do not. ScanJobsService.create allows reserve() to return reserved: false, creates a missing job, and enqueues it based only on created.created.

Abuse: Once DynamoDB actually removes a previously completed job, re-upload the same receipt at its deterministic key and resubmit the original attempt ID with exactly the original group context. The old spend marker makes the debit and daily-count increment a no-op, but a new job runs. Keep at least one unused credit: the current repository checks available balance before noticing the replay marker. An image is the clearest paid-OCR case; PDF starts separately reuse Textract's job token for seven days. AWS async idempotency.

Impact: Repeat paid image/model processing without a new debit or daily scan increment. This requires waiting for actual job expiry and exact context reuse; it is not an immediate unlimited replay of a completed cached job. A collection of historic attempts provides a recurring unmetered workload.

Validation: With a missing job and reserve() returning an old replay, the actual job service enqueued work. TTL passage, S3 re-upload, and Textract were simulated/not exercised live.

Fix: Require a fresh reservation before creating a new work generation. An expired retry should return an explicit terminal/expired response and require a new attempt ID for paid execution. Make reservation and durable work state atomic where possible, and retain a bounded terminal tombstone consistent with retry policy. Verify no state transition can enqueue work solely because the result cache was deleted.

Dependency and additional hardening observations

npm audit --omit=dev --json reported 14 affected package entries: 10 high and 4 moderate, with no critical entries. These are package/advisory results, not 14 demonstrated TailSpend exploits; transitive effects can repeat one underlying problem. See audit snapshot.

The lockfile contains decode-uri-component@0.2.2 via query-string@7.1.3 and Expo Router. The installed Expo Router URL-state code imports query-string. The reviewed advisory describes excessive CPU from malformed percent encoding and a fix in 0.5.0. This merits a targeted mobile deep-link regression and a compatible dependency update; mobile exploitation was not tested. Do not blindly apply the audit's proposed Expo Router downgrade. Maintainer security advisory.

Other flagged packages include xmldom, image-size, js-yaml, browserslist, brace-expansion, picomatch, and shell-quote, plus dependent Metro entries. Many belong to client/build tooling. A services-filtered audit still returned five entries from the shared workspace dependency graph; it is not proof those packages ship in Lambda. Verify bundled reachability while updating them.

Additional source observations, not promoted to demonstrated standalone exploits:

Existing protections that were verified

Reproduction and validation

Run the offline evidence script from the repository root:

node --import tsx docs/security/reproduce-2026-09-12.mjs

Result: seven checks passed, demonstrating the current problematic behaviors. They are assessment evidence, not assertions that the system is secure, and should fail/change when fixes land. External service boundaries are mocked; the script makes no paid calls.

Existing focused tests also passed: 73 tests across eight files, covering role checks, admin checks, request limits, scan jobs, OCR, credit repository transactions, AdMob verification, and participant addition. These establish existing guard behavior; they do not negate the new findings. No application implementation, dependency lockfile, or deployment configuration was modified.

  1. Cap PDF pages before starting paid work; add verified page-based and global work allowances. Close signup-grant recycling.
  2. Add early public/API throttles and upload quotas, then fix the expired-job replay transition.
  3. Require membership consent and decouple account deletion from third-party expense references.
  4. Cap resource growth, remove repeated fan-out, and maintain balance projections. Patch reachable dependency advisories and harden desktop/worker boundaries.
  5. Verify deployed settings with read-only AWS inspection, then run bounded staging tests using two dedicated accounts. Include quota contention, account recreation, TTL/replay, large PDFs, forced memberships, and limiter-store failure. Measure actual consumed capacity and charges before choosing production budgets.

View Markdown source