Security hardening (audit batch 1): project_id traversal + cross-origin write guard#33
Draft
DozaVisuals wants to merge 17 commits into
Draft
Security hardening (audit batch 1): project_id traversal + cross-origin write guard#33DozaVisuals wants to merge 17 commits into
DozaVisuals wants to merge 17 commits into
Conversation
… 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>
This reverts commit 430fbe1.
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>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
doza-assist | 7dfff4a | May 29 2026, 12:02 AM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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