Summary
Videos.duplicate() spreads the full source video into repo.create, which lets two
unrelated columns leak through unintended paths: createdAt/updatedAt are forged to the
original video's timestamp instead of getting a fresh one, and public is copied while
password is silently dropped.
Root cause
packages/web-backend/src/Videos/index.ts:413-425:
yield* repo.create(
{
...video,
source: publishedKeys.size > 0 ? { type: "desktopMP4" } : video.source,
metadata: Option.map(video.metadata, (metadata) => { ... }),
},
{ id: newVideoId },
);
video is a Video.Video instance (packages/web-domain/src/Video.ts:27-58), a
Schema.Class whose fields including createdAt, updatedAt, and public are own
enumerable instance properties. password is deliberately excluded from this class
(comment at line 26: "Purposefully doesn't include password as this is a public class"), so
it never rides along in the spread.
CreateVideoInput (packages/web-backend/src/Videos/VideosRepo.ts:11-14) is typed as
Omit<Schema.Type<typeof Video.Video>, "id" | "createdAt" | "updatedAt"> & {...}, but this
Omit only affects the compiler TypeScript does not run excess-property checks against a
spread, so createdAt/updatedAt pass through unflagged at both compile time and runtime.
create() (VideosRepo.ts:106-131) explicitly overrides nine keys
(id, orgId, bucket, storageIntegrationId, metadata, transcriptionStatus,
folderId, width, height, duration) before calling
db.insert(Db.videos).values([{ ...data, ... }]) but not createdAt, updatedAt, or
public. Both createdAt/updatedAt columns are .defaultNow()
(packages/database/schema.ts:439,446), which Drizzle/MySQL only apply when the key is
absent from the insert here it's present with the original video's value, so the default
never fires. password (schema.ts:448) is nullable with no default and is never a key on
data at all, so it comes out NULL.
Consequence 1: shareable-link quota bypass
SHAREABLE_LINK_LIMIT_ENFORCED_FROM = 2026-08-19T00:00:00.000Z
(packages/web-domain/src/Video.ts:22-24), and
apps/web/lib/shareable-link-quota.ts:32 exempts any video with
createdAt < SHAREABLE_LINK_LIMIT_ENFORCED_FROM from the free-tier 25-link/month cap,
permanently. Duplicating any video created before 2026-08-19 produces a new video row that
keeps the original createdAt, so the duplicate is also permanently quota-exempt. A free-tier
user can duplicate one old cap as many times as they like and get unlimited uncounted
shareable links.
Consequence 2: access-control regression
Duplicating a public: true + password-protected cap copies public: true forward but drops
the password (comes out NULL). The result is a public cap with no password, silently
created by a completely ordinary "duplicate" action no attacker or malicious intent required.
Consequence 3 (minor): dashboard sort
Duplicates keep the original's createdAt, so if the dashboard orders by creation date they
sort next to the original instead of appearing at the top, effectively burying themselves.
Existing work
None found. The quota feature landed via PR #2138 ("Shareable link limits", merged
2026-08-20) and never touched Videos/index.ts/duplicate(). No issue or PR references this
interaction, and there's no test coverage for Videos.duplicate()'s output fields at all.
Fix
- In
repo.create, build the insert payload by enumerating the columns that should carry over
from the source video, instead of spreading the whole Video.Video instance. Never let
id/createdAt/updatedAt reach the insert from the source object.
- Decide deliberately whether a duplicate should inherit password protection; if not, force
public: false on duplicates of password-protected videos (or carry the password forward
explicitly but silently dropping it while keeping public: true is the wrong default
either way).
Summary
Videos.duplicate()spreads the full source video intorepo.create, which lets twounrelated columns leak through unintended paths:
createdAt/updatedAtare forged to theoriginal video's timestamp instead of getting a fresh one, and
publicis copied whilepasswordis silently dropped.Root cause
packages/web-backend/src/Videos/index.ts:413-425:videois aVideo.Videoinstance (packages/web-domain/src/Video.ts:27-58), aSchema.Classwhose fields includingcreatedAt,updatedAt, andpublicare ownenumerable instance properties.
passwordis deliberately excluded from this class(comment at line 26: "Purposefully doesn't include password as this is a public class"), so
it never rides along in the spread.
CreateVideoInput(packages/web-backend/src/Videos/VideosRepo.ts:11-14) is typed asOmit<Schema.Type<typeof Video.Video>, "id" | "createdAt" | "updatedAt"> & {...}, but thisOmitonly affects the compiler TypeScript does not run excess-property checks against aspread, so
createdAt/updatedAtpass through unflagged at both compile time and runtime.create()(VideosRepo.ts:106-131) explicitly overrides nine keys(
id,orgId,bucket,storageIntegrationId,metadata,transcriptionStatus,folderId,width,height,duration) before callingdb.insert(Db.videos).values([{ ...data, ... }])but notcreatedAt,updatedAt, orpublic. BothcreatedAt/updatedAtcolumns are.defaultNow()(
packages/database/schema.ts:439,446), which Drizzle/MySQL only apply when the key isabsent from the insert here it's present with the original video's value, so the default
never fires.
password(schema.ts:448) is nullable with no default and is never a key ondataat all, so it comes out NULL.Consequence 1: shareable-link quota bypass
SHAREABLE_LINK_LIMIT_ENFORCED_FROM = 2026-08-19T00:00:00.000Z(
packages/web-domain/src/Video.ts:22-24), andapps/web/lib/shareable-link-quota.ts:32exempts any video withcreatedAt < SHAREABLE_LINK_LIMIT_ENFORCED_FROMfrom the free-tier 25-link/month cap,permanently. Duplicating any video created before 2026-08-19 produces a new video row that
keeps the original
createdAt, so the duplicate is also permanently quota-exempt. A free-tieruser can duplicate one old cap as many times as they like and get unlimited uncounted
shareable links.
Consequence 2: access-control regression
Duplicating a
public: true+ password-protected cap copiespublic: trueforward but dropsthe password (comes out NULL). The result is a public cap with no password, silently
created by a completely ordinary "duplicate" action no attacker or malicious intent required.
Consequence 3 (minor): dashboard sort
Duplicates keep the original's
createdAt, so if the dashboard orders by creation date theysort next to the original instead of appearing at the top, effectively burying themselves.
Existing work
None found. The quota feature landed via PR #2138 ("Shareable link limits", merged
2026-08-20) and never touched
Videos/index.ts/duplicate(). No issue or PR references thisinteraction, and there's no test coverage for
Videos.duplicate()'s output fields at all.Fix
repo.create, build the insert payload by enumerating the columns that should carry overfrom the source video, instead of spreading the whole
Video.Videoinstance. Never letid/createdAt/updatedAtreach the insert from the source object.public: falseon duplicates of password-protected videos (or carry the password forwardexplicitly but silently dropping it while keeping
public: trueis the wrong defaulteither way).