Skip to content

Videos.duplicate() copies createdAt and public, drops password → shareable-link quota bypass and public exposure of password-protected caps #2223

Description

@addyCooks

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions