Add TransferTribe app and fix critical transfer security flaws

The application source was untracked, so this commit brings it under
version control together with fixes for the issues found while auditing
it. Notable fixes:

Authorization
- Require a session and scope to senderEmail on /api/transfers/[id]
  (GET, DELETE) and .../resend. These were unauthenticated over an
  autoincrement id, so the ids could be walked to soft-delete any
  transfer, read sender/recipient metadata, or make the app mail
  arbitrary recipients.
- Require a session on the legacy /api/send and /api/chunk-upload
  endpoints, and take the sender from the session rather than a request
  field so transfers cannot be posted as another user.

Encryption
- Replace the chunk encryption scheme. Every chunk was encrypted under
  one shared session IV with its auth tag discarded, which reuses the
  AES-GCM keystream (XORing two ciphertexts recovers plaintext without
  the key) and left the stored file undecryptable, surfacing to users as
  a wrong-password error. Chunks are now self-contained frames carrying
  their own random IV and auth tag, behind a magic+salt header.
- Files written by the previous format now report UNSUPPORTED_FORMAT
  instead of a misleading password error.

Download
- Verify the password against the stored bcrypt hash before serving a
  file, and enforce expiresAt and DELETED/EXPIRED status.
- Move the password from the query string into a POST body so it stays
  out of access logs and Referer headers.
- Record a download only after successful authentication.
- Decrypt frame by frame through a stream instead of buffering the whole
  file, and encode the Content-Disposition filename per RFC 5987.

Data exposure
- /api/download ran before the password prompt and returned the full
  transfer row, including absolute server file paths. It now returns
  only what the pre-password screen renders; filenames, message and
  recipient are withheld until /api/verify succeeds.

Correctness
- Fix BigInt handling that made /api/transfers and /api/transfers/[id]
  fail unconditionally (JSON.stringify cannot serialize BigInt, and
  seeding a BigInt reduce with 0 throws).
- Fail loudly on a missing chunk during reassembly rather than silently
  writing a corrupt file.
- Meter plan usage in plaintext bytes rather than on-disk encrypted size.

Ignore /uploads: it holds runtime transfer payloads, not source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-08 08:48:22 +02:00
parent 95a4bc74fa
commit 2e688c8e52
64 changed files with 9268 additions and 169 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "File" ADD COLUMN "encryptionIv" TEXT;
+110
View File
@@ -0,0 +1,110 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum TransferStatus {
ACTIVE
EXPIRED
DELETED
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
plan String @default("free") // "free", "rookie", or "pro"
// Optional
transfers Transfer[] @relation("UserTransfers")
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Transfer {
id Int @id @default(autoincrement())
senderEmail String
recipientEmail String
passwordHash String?
downloadUrl String @unique
createdAt DateTime @default(now())
expiresAt DateTime
files File[]
filenames String? // JSON stringified array of filenames
totalFiles Int @default(0) // new field to track number of files in transfer
totalSize BigInt @default(0) // Use BigInt to support files > 2GB
message String? // optional message
status TransferStatus @default(ACTIVE) // Added
userId String? // Change from Int? to String?
user User? @relation("UserTransfers", fields: [userId], references: [id])
}
model File {
id Int @id @default(autoincrement())
name String
path String
size BigInt // Use BigInt to support files > 2GB
encryptionIv String? // 12-byte IV as base64 string for AES-GCM decryption
transferId Int
transfer Transfer @relation(fields: [transferId], references: [id])
downloads FileDownload[] // New
}
model FileDownload {
id Int @id @default(autoincrement())
fileId Int
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
timestamp DateTime @default(now())
ip String?
userAgent String?
}