Skip to content

Security hardening (audit batch 1): project_id traversal + cross-origin write guard#33

Draft
DozaVisuals wants to merge 17 commits into
mainfrom
security-hardening
Draft

Security hardening (audit batch 1): project_id traversal + cross-origin write guard#33
DozaVisuals wants to merge 17 commits into
mainfrom
security-hardening

Conversation

@DozaVisuals

Copy link
Copy Markdown
Owner

Security hardening (audit batch 1)

Three reviewed commits, app.py only, +192/−4. Investigation-first audit, each fix verified against the full core test suite with zero new failures vs clean main HEAD.

SEC-02 — project_id path traversal (de2b2de)

delete_project ran shutil.rmtree on an unvalidated project_id. Added a shared validator (validate_project_id + safe_project_dir) and a before_request guard covering every /project// route, plus the body-sourced id in send-to-nle. Multi-project comma-joined chat preserved (each component validated). Symlink-escape, ../, absolute paths, dotted names all rejected.

SEC-01 — cross-origin write guard (605b3d5)

Local Flask API had no Origin/CSRF check. Added a guard on state-changing methods (POST/PUT/PATCH/DELETE) allowing only loopback Origin + loopback Host (Host check defeats DNS rebinding). Loopback binding unchanged. Safe methods and all page loads, including the share portal GET, unaffected.

SEC-01 portal exemption (7dfff4a)

Client review portal reaches the app cross-origin via the Cloudflare tunnel. Scoped exemption so portal clients can add selects, add clips, chat, and comment, but cannot delete, shrink, edit, or reorder existing clips/selects.

  • Chat send exempted outright; clear_chat (DELETE) stays blocked.
  • save_labels and save_selects allowed cross-origin only as pure additions (subsequence + strictly longer), checked against server-side stored state, not client-supplied prior state. Edits, removals, reorders, and label-color-map changes blocked 403.
  • story_update and all destructive/editor endpoints stay blocked cross-origin.
  • Same-origin editor behavior unchanged: full add/remove/edit/reorder.

Verification

Dedicated security test-client scripts pass (53/53 SEC-02, 12/12 SEC-01, 25/25 portal). Full core suite: failure set identical to clean main HEAD (36 pre-existing, unrelated, 0 new). No Pro-only logic; app.py only; license surface unchanged.

Not merging yet

Pending a live portal smoke-test through the tunnel before merge: as client, add select/clip/chat/comment works and delete-clip blocked; as editor, edit/reorder works.

🤖 Generated with Claude Code

DozaVisuals and others added 17 commits May 12, 2026 11:23
… upgrades

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 0.7.0 Electron shell ships ffmpeg + ffprobe inside the .app at
Contents/Resources/ffmpeg/bin/ and exports DOZA_FFMPEG_DIR pointing at
it. This change has _find_ffmpeg and _ensure_ffmpeg_on_path check that
env var first so the deterministic vendored binary is used instead of
whatever Homebrew may have installed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ai_analysis.py
- get_effective_ollama_model() now returns (None, selected_variant,
  True) when no gemma4 variant is installed, instead of a misleading
  "first available" model from a different family. 0.7.0 paired with
  the wrong-tag bug in main.js produced a phantom "Currently loaded:
  gemma3:4b" badge.
- Ollama tags-API timeout 5s → 2s so /api/ai-model/status responds
  faster when the user opens AI Settings before Ollama has fully
  booted.

app.py
- @app.context_processor inject_brand() auto-detects the Pro overlay
  by checking for a sibling ../pro/ directory relative to __file__
  (was relying solely on DOZA_BRAND env var, which 0.7.0 never set).
  Pro installs now show the correct "Doza Assist Pro" brand and
  /collection/static/logo-pro.png.
- app_version falls back to DOZA_WRAPPER_VERSION env var when set so
  the page footer shows the wrapper version (0.8.0) not the OSS
  Flask app version (3.3.0).

model_config.py
- _get_ram_gb() now uses os.sysconf(SC_PAGE_SIZE * SC_PHYS_PAGES) as
  primary, /usr/sbin/sysctl as absolute-path fallback. 0.7.0 called
  bare-name `sysctl` via subprocess which failed inside the bundled
  Flask's sanitized PATH (no /usr/sbin) and every Mac defaulted to
  8.0 GB.
Before: choosing DaVinci Resolve as the editing platform produced a
plain-text EDL on disk, and clicking Export ran ``open -a Resolve
<file.edl>``. That focuses Resolve but does nothing else — Resolve has
no Finder-open auto-import for EDL or XML, unlike FCP's .fcpxml. The
user saw a "Sent to DaVinci Resolve" toast and an empty editor.

Three changes, all three required to land the FCP feel for Resolve:

1. exporters/resolve_xml.py + router swap

   Resolve now defaults to FCP7 XML (xmeml v5) instead of CMX 3600 EDL.
   FCP7 XML preserves clip names verbatim, carries notes per clip,
   embeds absolute source-media paths via <pathurl>, and Resolve's
   ImportTimelineFromFile reconnects media automatically. EDL stays
   available via the new ``resolve-edl`` platform key for niche
   workflows that explicitly need CMX 3600 (hardware deck handoff,
   legacy color systems).

   The exporter itself is a thin subclass of PremiereXMLExporter (which
   already emits xmeml v5) — just relabels format_name + platform_name
   + the file extension. No new format code.

2. exporters/resolve_import.py + app.py wiring

   New scripting-API integration. On Resolve export:
     - Probe whether DaVinciResolveScript module is reachable.
     - Launch Resolve if not running, poll for the scripting daemon.
     - GetCurrentProject() / CreateProject() so we have a place to land.
     - AddItemListToMediaPool([source_media_path]) to import source.
     - MediaPool.ImportTimelineFromFile(xml_path, {timelineName, ...})
       to create the new timeline.
     - SetCurrentTimeline(...) so the user lands on the result.

   _hand_file_to_nle('resolve', ...) tries the scripting path first.
   If scripting is unreachable, falls back to reveal-in-Finder + focus
   the app, and tags the response with setup_required so the frontend
   can surface a setup modal. Distinct reason codes (module_missing,
   scripting_disabled, no_project, import_failed) so we can render the
   right remediation.

   Works on both Resolve Free and Studio (the methods used are
   non-Studio-restricted). All scripting IPC is local Mach ports;
   nothing here phones home — fully offline.

3. templates/project.html

   - One-time setup modal that walks the user through enabling
     External Scripting (Resolve → Preferences → System → General →
     "External scripting using: Local"). Triggered automatically when
     an export to Resolve returns setup_required. "Test again" retries
     the export in-place after the user flips the preference; "Use
     Finder instead" dismisses and keeps the Finder-reveal fallback.
   - formatExportToast() is now opened_in-aware: 'scripted' →
     "auto-imported into Resolve" with the timeline name; 'finder+app'
     → explicit "drag into Media Pool" guidance; 'app' (FCP) keeps the
     old "auto-imported" copy.
   - All three export call sites (Clips tab, Export tab, Story
     Builder) now package their POST body into a retry closure that
     the setup modal's "Test again" button can re-run without making
     the user click Export twice.
   - FORMAT_NAMES/EXPORT_LABELS updated: resolve format flips from
     "EDL" to "FCP7 XML", and the Resolve card-blurb explains the new
     auto-import + setup requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Confirmed via sweeping every panel of Preferences in Resolve 20 Free:
Blackmagic does not expose the External Scripting toggle outside of
Studio. The hint we previously surfaced — "open Preferences → System
→ General → External scripting using: → Local" — sends Free users
chasing a setting that doesn't exist in their build.

Detection: read /Applications/DaVinci Resolve/DaVinci Resolve.app/
Contents/Info.plist's CFBundleName via `defaults read`. Studio bundles
that name as "DaVinci Resolve Studio"; Free as "DaVinci Resolve". The
check is cheap, runs out of process, and works even when the scripting
API itself is unreachable.

Backend (exporters/resolve_import.py):
  - New ``edition()`` helper returns 'studio' / 'free' / 'unknown'.
  - probe() and import_timeline() now branch on edition() when
    scriptapp() returns None. Free → ``reason='requires_studio'`` with
    a "drag into Media Pool" hint. Studio → the existing
    ``reason='scripting_disabled'`` with the preferences walkthrough.

Frontend (templates/project.html):
  - Modal markup adds new ids (#resolveSetupTitle, #resolveSetupSteps,
    #resolveSetupFreeNote, #resolveSetupTrailer) so JS can swap which
    sections are visible.
  - maybeShowResolveSetup branches on reason: ``requires_studio``
    hides the walkthrough + "Test again" retry, swaps the title to
    "Auto-import needs Resolve Studio", shows the Free-specific
    drag-into-Media-Pool note, and turns the dismiss button into
    "Got it" (no retry can change the outcome). ``scripting_disabled``
    keeps the existing Studio walkthrough.

Net effect: Free users see honest copy ("requires Studio — here's how
the Finder flow works"), Studio users with the toggle off see the
walkthrough, and Studio users with the toggle on never see the modal
because the scripted import succeeded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pool

Verified live: dragging an FCP7 XML into Resolve's Media Pool does
nothing — Resolve treats it as a media file rather than a timeline
exchange format. The documented import path (File → Import →
Timeline… → pick the .xml) works perfectly: Resolve opens the Load
XML dialog, accepts the defaults (Automatically set project settings,
Automatically import source clips into media pool), creates the
timeline, and auto-links the source media via the <pathurl> entries
in the XML. Re-imported a 4-clip Story Builder export end-to-end —
timeline + source clip appeared in the Media Pool, viewer played the
actual footage.

Updating Free-path copy in three places so users don't bounce off
the wrong action:

1. exporters/resolve_import.py — both probe() and import_timeline()
   now return a hint that says "File → Import → Timeline… → choose
   the XML" instead of "drag into Media Pool".

2. templates/project.html — the requires_studio modal now shows a
   numbered four-step list (Switch to Resolve → File → Import →
   Timeline… → pick the XML → click OK) plus a one-liner explaining
   why dragging doesn't work. formatExportToast's finder+app message
   for Resolve mirrors the same path.

3. EXPORT_LABELS card-blurb for Resolve: split the explanation into
   Studio (auto-imports) vs Free (manual via File → Import →
   Timeline). Removes the misleading "drag from Finder" guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: Imported Resolve timeline shows V1 + A1 + A2 clips at the
right positions, viewer plays the video, but the audio tracks are
silent. Verified live with a fresh ST Gala XML import: the cuts
landed, video played the gala footage, but timeline playback had no
sound.

Root cause: FCP7 XML <clipitem> elements on audio tracks need a
<sourcetrack><mediatype>audio</mediatype><trackindex>N</trackindex>
</sourcetrack> child telling the NLE which channel of the source
file to pull from. Without it, Resolve and Premiere both place the
clip but route silence — the timeline track is "connected" to no
source stream.

Fix: _add_clipitem grows an optional audio_channel arg; the audio
loop in _build_sequence passes ch_index (1 for A1, 2 for A2). Video
clipitems still skip the sourcetrack — video files only have one
stream, so no channel routing is required there. If a source ends
up being mono, Resolve handles a channel-2 reference gracefully
(plays silence on A2, full audio on A1).

Verified by re-running the exporter on a synthetic 2-marker project
and confirming the rendered XML contains 4 <sourcetrack> blocks (one
per A1+A2 × 2 cuts) with trackindex 1 and 2 alternating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two of Resolve's import-flow dialogs look alarming but don't actually
mean anything broke: the "The clip was not found / select another
folder to search?" prompt and the Log window listing "File not found
in search directories" warnings. The timeline imports correctly
through both — Resolve still matches the absolute <pathurl> in the
XML. Verified live: clicked through both and the timeline + source
clip landed in the Media Pool, viewer played the gala footage.

Without warning, a first-time user is likely to bail on the import
when the "clip not found" sheet appears. Adding two extra steps to
the Free-path modal checklist (click No, close the Log) plus an
expanded explainer paragraph saying "the timeline imports correctly
anyway — Resolve is matching on the absolute source-media path in
the XML." Same modal already says drag-into-pool doesn't work and
points at File → Import → Timeline as the supported path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verified live: imported a fresh export into DaVinci Resolve 20 Free
and confirmed audio plays through the timeline. The fix unblocks the
last real complaint with the Resolve export path.

What failed with FCP7 XML (xmeml v5): Resolve imported the video and
timeline structure cleanly, but the audio clips landed as red
placeholders with no waveform — Resolve couldn't bind the clipitems'
<sourcetrack> entries to source-file audio tracks. Tried adding
sourcetrack to clipitems, declaring source audio tracks via <track>
children, splitting/unifying timeline audio tracks — none of them
got Resolve to wire audio through. The FCP7 schema variants Resolve
accepts for audio routing are brittle to emit deterministically.

Switching Resolve to Apple's modern FCPXML (.fcpxml) format side-steps
all of that. Resolve has first-class FCPXML support; the schema
declares audio per-clip with explicit channel mapping, so the
importer creates GREEN stereo audio clips in the timeline and the
audio meters light up on playback. The FCPXMLExporter has been in
the codebase for the FCP path the whole time — we just had Resolve
pointed at the wrong exporter.

Changes:

1. exporters/resolve_xml.py: ResolveFCPXMLExporter is a thin subclass
   of FCPXMLExporter that only retags platform_name to "DaVinci
   Resolve" so toasts and metadata stay accurate. ResolveXMLExporter
   (FCP7 XML) is preserved for the niche workflow where someone needs
   to round-trip through Premiere on the way to Resolve; the module
   docstring captures the why.

2. exporters/router.py: "resolve" registry key now points at the new
   ResolveFCPXMLExporter (FCPXML, .fcpxml). FCP7 XML for Resolve is
   moved behind the opt-in "resolve-xml" key. EDL stays at
   "resolve-edl". The docstring documents why FCPXML is the default.

3. templates/project.html: FORMAT_NAMES.resolve flipped from "FCP7
   XML" to "FCPXML". card-blurb explains the audio behavior so users
   know what they're getting. Free-path modal walkthrough now points
   at the .fcpxml extension (was .xml) and updates the explainer
   paragraph to reflect FCPXML's clip-by-clip audio mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ization gate

Bug fixes shipping in Doza Assist Pro 0.8.19:

* NLE export ("FCP not found at expected location"): _find_nle_app_path
  now uses mdfind on CFBundleIdentifier so users who install FCP /
  Premiere / Resolve outside /Applications (~/Applications, Setapp,
  external volumes, year-versioned Adobe dirs, Resolve Studio vs Free)
  are discoverable. Falls back to the previous hardcoded paths only if
  Spotlight returns nothing. FCP launch now also uses `open -b
  com.apple.FinalCut` so Launch Services owns the path lookup
  end-to-end. Error toast rewritten to be actionable. Logger warning
  emits the searched bundle IDs and fallback paths on failure for
  faster support triage.

* Background-thread /transcribe (the 0.8.10+ architecture this
  diarization fix depends on): transcribe.py adds progress callbacks
  through every engine (parakeet / whisperx / whisper / lightning),
  app.py adds _run_transcribe_job + transcribe_status route, and the
  worker thread's completion handler enqueues diarization explicitly
  (the @after_request hook only fired on 200, never on the new 202
  kickoff response — silent drop in 0.8.10+).

* Server-side speaker-rename gate (test_diarization_speaker_gate.py):
  /update-speakers and /update-speaker-range now return 409 on diarized
  projects so the JS lockout race that collapsed 5 SPEAKER_NN labels
  into a single "Chris" across 1167 segments can't reach disk again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace auto-download behavior with an in-app banner that lets users
decide when to download and install updates (like Claude desktop).
New _update_banner.html component included in dashboard, project,
my_style, and review templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The diarization module swaps #transcriptContainer.innerHTML on every
page load for already-diarized projects and calls window.rebuildWordIndex()
afterward — but that function was never defined. allWords kept pointing
at the old detached .tw nodes, so allWords.indexOf(newWord) always
returned -1, getWordRange() always returned [], and commitSelection()
silently bailed. Drag-to-paint, click-to-toggle-label, and playback
word highlighting all broke. Symptom: editor selects a color, drags
across words, nothing happens.

Defined window.rebuildWordIndex in project.html to:
  - Mutate allWords in place (it's const — clear length, re-push)
  - Rebuild wordsByProject lookup
  - Reset lastActiveWord (was a detached node ref)
  - Re-init transcript module with preserveBrush:true so existing
    labeled sections get repainted onto the new DOM words without
    nuking the editor's current color choice
  - Reset transcript search state (stale .tw match nodes)

Bug fired on every project page load for diarized projects, not just
during a diarization run, because diarization.js's onLoad → tick()
sees status:'done' and unconditionally calls refreshTranscriptPane().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A newly-created project gets status='transcribing' the moment the
background worker picks it up. Two template conditionals together left
this state with no UI:

  Line 142: {% if project.status == 'uploaded' %}   ← progress card
  Line 159: {% if project.transcript %}             ← tabs + content

Status flow: uploaded → transcribing → transcribed
                                       (transcript field populated)

When status is 'transcribing':
  - First condition is False (status != uploaded)
  - Second condition is False (transcript still empty)
  → Neither block renders.

Result: page shows only the title-area (file name, EDIT IN buttons,
Reveal in Finder), then blank. Editors create a project, see nothing,
assume the app is broken, delete the project, repeat. Confirmed via
Chrome DevTools Protocol on the running 0.8.24 build —
container-wide.offsetHeight = 149px, no .tabs node, no .page-two-col
node, despite Flask serving the full template.

Changes:
  - Show the progress card whenever there's no transcript
    ({% if not project.transcript %}), not only when status == uploaded.
    Covers uploaded, transcribing, and any other pre-transcribed state.
  - Gate startTranscription() auto-start to status == 'uploaded' so a
    project that already has a worker mid-flight doesn't get double-
    triggered.
  - Added a polling script that hits /project/<id>/transcribe/status
    every 2s, updates the progress card's text + bar from the server's
    {phase, pct} payload, and reloads the page (via reloadAndKeepTab)
    when phase == 'done'. So the user lands on the full tabs/transcript
    UI automatically.

Verified end-to-end:
  - Project bca09994 (status: transcribing, no transcript) now renders
    the "Extract_audio…" card with a live progress bar instead of blank.
  - Polling fires every 2s (GET /project/<id>/transcribe/status → 200)
    and reflects each phase as the worker advances.
delete_project passed an unvalidated project_id straight into shutil.rmtree,
so a crafted id ("../...", an absolute path, or a symlinked dir) could escape
PROJECTS_DIR and delete arbitrary directories. Enforce one shared validator
across the whole /project/<project_id>/... surface:

- validate_project_id(): strict single-id token (no separators, no "..",
  not absolute, length-bounded).
- safe_project_dir(): resolve id -> absolute dir; reject anything that isn't a
  direct child of PROJECTS_DIR (also catches a symlinked project dir pointing
  outside). Single source of truth for project_id -> path.
- before_request guard: one enforcement point for every <project_id> route;
  mirrors the routes that accept comma-separated id lists (multi-project chat
  and workspace view) by requiring every component to be valid.
- get_project / save_project / delete_project and /export/send-to-nle now go
  through the validator, so rmtree can only ever target a direct child of
  PROJECTS_DIR.

Generic hardening only; no Pro logic, no license-surface change.

Verified: normal delete works (200, dir removed); "../", absolute, dotted,
over-length and symlink-escape ids are rejected and rmtree never runs on them;
multi-project comma-joined chat still works; full core test suite failure set
is identical to clean main HEAD (zero new failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Flask API binds to 127.0.0.1 only, but loopback binding does not stop a
website the editor visits from issuing cross-origin POST/PUT/PATCH/DELETE to
http://127.0.0.1:<port>/... (CSRF), and does not stop DNS rebinding. There was
no CORS policy and no auth. Add a before_request guard requiring state-changing
requests to look same-origin to the loopback app:
- Origin (when present) must be loopback, AND
- Host must be loopback (this second check defeats DNS rebinding, where the
  page's Host is the attacker's domain resolving to 127.0.0.1).
Safe methods (GET/HEAD/OPTIONS) are never blocked, so page loads -- including
the shared client portal at GET /share/<id> -- are unaffected.

Exception: the client review/share portal is reached cross-origin via a
Cloudflare tunnel, so its Origin is the tunnel URL. Its two documented client
writes -- save_selects and add_comment -- are exempted so the portal keeps
working; they are low-impact and the portal is public-by-design (the URL is the
only barrier). The editor's sensitive mutating endpoints (delete/clear/
retranscribe/rename/move/create/upload, provider + share settings, find-file,
browse, ...) stay protected.

Generic hardening only; no Pro logic, no license-surface change.

Verified: editor same-origin writes (loopback Origin+Host, and the no-Origin
localhost path) work; foreign-Origin and DNS-rebinding writes return 403 and
nothing is deleted; cross-origin GET and the portal page still load; portal
selects/comments writes still work; full core test suite failure set is
identical to clean main HEAD (zero new failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a cross-origin review-portal client (reached via the Cloudflare tunnel)
ADD clips/selects and use chat, while still blocking removal, shrink, edit,
reorder, and every editor-only/destructive endpoint.

- Chat: add `chat` (/chat) and `chat_stream` (/chat-stream) to the portal
  write-exemption list. They don't remove anything; clear_chat (DELETE /chat)
  is a separate endpoint and stays blocked.
- save_selects + save_labels: both replace the whole list, so add and delete
  are the same endpoint. For CROSS-ORIGIN callers only, allow the write when
  the submitted list is a *pure addition* to the stored list — every stored
  item still present, unchanged, in the same relative order (subsequence), and
  the list strictly longer. Removal/shrink/edit/reorder -> 403. For save_labels
  the color_labels map must also be unchanged (no renaming labels cross-origin).
- This tightens the batch-2 save_selects exemption (which allowed removal);
  that tightening is intended.
- Same-origin editor requests are never subject to the append-only rule — the
  editor keeps full add/remove/edit/reorder on both endpoints.
- Everything else stays blocked cross-origin: story_update (PUT list-replace),
  clear_chat, delete/clear/retranscribe/rename/move/create/upload, provider +
  share settings, find-file, browse, export, etc.

Generic hardening only; no Pro logic, no license-surface change.

Verified (test-client, 25 checks): cross-origin add-select/add-clip/chat/
comment work; cross-origin remove/shrink/edit/reorder/color-edit/clear-chat/
delete/rename/retranscribe/provider-settings/story-update all 403; same-origin
editor add/remove/edit/reorder still work. Full core test suite failure set is
identical to clean main HEAD (zero new failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
doza-assist 7dfff4a May 29 2026, 12:02 AM

@DozaVisuals DozaVisuals marked this pull request as draft May 29, 2026 00:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant