Scripted playout build almost certainly 401s: ScriptedScheduleController is under the global API auth filter and the script is handed no credential #913
Open
opened 2026-09-05 07:15:40 +02:00 by timothy
·
5 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
renovate/nuget-sixlabors.imagesharp-vulnerability
913-scripted-playout-401
929-vuln-pkcs-imagesharp-v4
renovate/dotnet-monorepo
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#913
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Derived from source while closing #563 (measured 2026-09-05 against
origin/main@4a56d54). Not yet reproduced against a live instance — that is step 1 below.The chain
Startup.ConfigureServicesregisters the filter globally:options.Filters.AddService<ApiAuthorizationFilter>().ApiAuthorizationFilter.EndpointRequiresKey(ErsatzTV/Filters/ApiAuthorizationFilter.cs) returnstruefor every mutating verb unconditionally —return IsMutating(httpMethod) || requireKeyForReads || ...— and for reads too, sinceApi:RequireKeyForReadsdefaults totrue(ApiKeyProvider).ErsatzTV/Controllers/Api/ScriptedScheduleController.cscarries no[SkipApiAuthorization](grep -n SkipApiAuthorizationon that file exits 1). Its route is/api/v1/scripted/playout/build/{buildId:guid}, i.e. under/api, so the filter's path guard does not exempt it.ScriptedPlayoutBuilder(ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs) hands the user's program only three arguments:http://localhost:{Settings.UiPort}, the build id, and the mode. NoX-Api-Key, no session.scripts/scripted-schedules/entrypoint.py, builds its client asetv_client.Configuration(host=known_args.host)— no credential either.With neither credential presented,
OnAuthorizationfalls through to theUnauthorizedObjectResult, so the script's firstPOST .../add_collectionshould get a 401 and the scripted build should produce nothing.Why it is not covered by tests
#563 characterizes scripted playout in-process (decision
testing.scripted-engine-in-process-net):ScriptedScheduleControllerTestsinvokes controller actions directly, so no filter, no middleware, no Kestrel. The record namesApiAuthorizationFilteras the concrete blind spot that scope-out leaves; this issue is that blind spot.Scope
/api/v1/scripted/playout/build/.[SkipApiAuthorization]on a whole mutating controller is a widening, so weigh it against minting a build-scoped credential thatScriptedPlayoutBuilderpasses to the script as a fourth argument.EndpointRequiresKeyis a pure static — it is testable without a host).Done-when
ScriptedScheduleController(not just the action)docs/decisions/records/testing/scripted-engine-in-process-net.mdupdated so the 401 clause reflects the outcomeRefs #563.
Claiming #913 (orchestrated slot, session f9ce6bc6). Branch
913-scripted-playout-401, worktree/Users/timothy/orca/workspaces/ersatztv/wt-913. Four-way check clean: no open PR references #913, no remote branch matching*913*, no prior comments; origin/main @e919b925f.Reproduced (2026-10-29) — confirmed against a live instance
Instance: local live build of
origin/main@e919b925f, launched withETV_UI_PORT=8510 scripts/e2e-local.sh <fresh config>(real Kestrel, realStartuppipeline, realApiAuthorizationFilter, defaultApi:RequireKeyForReads=true). I used a local instance rather thanersatztv-testso no channel or playout had to be created on a shared instance.Method: created a channel and a
Scriptedplayout (POST /api/v1/playouts,scheduleKind: Scripted) whosescheduleFilewas a small probe program. The probe makes the same callsentrypoint.pymakes, at the HTTP level, with no credential (the same credentialentrypoint.pysends: none), records each status code, and exits 0, asentrypoint.pydoes after catchingApiException.Evidence:
Building scripted playout …/probe.py with arguments ["http://localhost:8510", "49a4b32b-2a88-4bfb-a0ea-dc22e4a99b66", "reset"], so the program got exactly host, build id and mode.GET /api/v1/scripted/playout/build/49a4b32b-…/context→ 401{"title":"Unauthorized","status":401,"detail":"Authentication is required. Provide a valid 'X-Api-Key' header (machine clients) or sign in to obtain a session (browser)."}POST …/add_collection→ 401 (same body)GET …/contextwithX-Api-Keyreturned 404Active build engine not found(auth passed; the session had ended), and 401 without the key. So the 401 comes from the auth filter and is not a missing route.GET /api/v1/playouts/1→"buildStatus":{"lastBuild":"…","success":true}with no items. The build reports success and is empty. That is the silent part: a program that exits 0 after the 401 looks like a clean build.The chain in the issue body holds. Fix: branch
913-scripted-playout-401. The widening-vs-credential argument goes in the PR body.Fix verified live (2026-10-29), branch
913-scripted-playout-401@0721d5de2(not pushed yet; under review)Choice: a build-scoped credential, not
[SkipApiAuthorization]. The argument is in the decision recordapi.scripted-build-tokenand will be in the PR body. The token is minted per build (256-bit), passed in theETV_SCRIPTED_BUILD_TOKENenvironment variable (not as a 4th argument: that would shift the user's own arguments, and argv is logged), and accepted asX-Scripted-Build-Tokenonly onScriptedScheduleController(marked[AcceptsScriptedBuildToken]), only for the route'sbuildId, and only until the build ends.Ran on a fresh local instance (
ETV_UI_PORT=8510 scripts/e2e-local.sh, fixed build):The fork's real driver, end to end. I ran
scripts/scripted-schedules/entrypoint.py(byte-identical copy,cmpclean) with the generatedetv_clientcopied read-only from theersatztv-testimage, against an OtherVideos library of 3 scanned files and a collection holding them. A tiny user script didadd_collection+add_count(3). Result: every call succeeded,GET /api/v1/playouts/1/items→totalCount: 3(probe1/2/3, 20s each), andbuildStatus.success: true.Controls, run as the scripted program during its own build:
GET contextPOST add_collectionGET /api/v1/channelswith own tokenPOST /api/v1/collectionswith own tokenThe token appears 0 times in the server log. The build id still appears in the INF argv line, as before; it is no longer the secret.
Tests:
ErsatzTV.Tests/Filters/ScriptedScheduleAuthorizationTests.cschecks the filter decision over the endpoint metadata MVC builds from the productionStartup, for all 28 controller actions.ErsatzTV.Core.Tests/Scheduling/ScriptedScheduling/ScriptedBuildTokenTests.cscovers token minting and validation and the launch environment. The clause-mutation results are in the commit message.Follow-up filed: #937 (
entrypoint.pyexits 0 after an API error, so a failed build reports success).Adversarial review — 0721d5de214833ecc19099c48ec3c6fc65981d9c
Reviewed
git diff origin/main...HEADin wt-913 for correctness and security. No code changes made.Blocking code findings: none identified. The credential is CSPRNG-generated independently of the logged build id, bound to the route's active build and the marked controller, compared with FixedTimeEquals, and removed by the builder's finally cleanup. Process arguments stay unchanged; entrypoint.py sends the environment-delivered token as a header. The credential choice avoids exposing the whole machine API key or exempting a mutating controller.
Should-fix / done-condition gap: no PR could be found by main...913-scripted-playout-401 or by HEAD commit. The required widening-vs-credential argument exists in docs/decisions/records/api/scripted-build-token.md but is not yet verifiable in a PR body. Create/push the PR with that argument before declaring #913 complete. This is a handoff gap, not an identified code defect.
Evidence: live reproduction comment #34584 and fixed-build verification #34586 were read; the latter records the real entrypoint/generated client producing three items plus no-token/wrong-token/cross-build/unmarked-endpoint/ended-token controls. ScriptedScheduleAuthorizationTests pins the production MVC metadata/filter decision; the requested testing decision record is updated. The existing silent API-error exit behavior is tracked separately as #937.
Verification: git diff --check origin/main...HEAD passed; working tree clean. No fresh .NET restore/build/test or live instance execution: this review session is read-only with approval disabled, while repository instructions require .NET verification outside the sandbox. Prior live and mutation results are recorded evidence, not runs performed by this reviewer.
Outcome: correctness/security source review passed; send-back for the missing PR-body acceptance evidence. Leave issue open until that handoff requirement is met.
Closing record
Outcome: PR http://192.168.1.95:3000/timothy/ersatztv/pulls/939 (head
324dbdbca). Confirmed and fixed; merge closes this issue.Root cause:
ScriptedScheduleControlleris under the globalApiAuthorizationFilter, and the program a scripted playout launches received no credential (host, build id and mode only;entrypoint.pybuilt its client without one), so every callback was a 401. #563 tests the engine in-process with no filter, so nothing saw it.Decisions/conventions changed: added
api.scripted-build-token(build-scoped token viaETV_SCRIPTED_BUILD_TOKEN/X-Scripted-Build-Token;[SkipApiAuthorization]on the controller rejected);testing.scripted-engine-in-process-net401 clause updated.Reusable knowledge: a program launched by the server needs its credential in the environment, not argv (argv is logged and in the process table). Live-check this path with a plain executable as the
scheduleFile.Verification: live E2E 2026-10-30 on port 8510: no credential 401; build token 200; same token on another build 401; machine key on unknown build 404; bogus token 401; launch args in the log carry no token. Full .NET suites, web (vitest 1371), scripts/tests (1615) green.
Deferred: none.
Docs updated: docs/api-conventions.md, docs/testing.md, docs/decisions/records/api/scripted-build-token.md, docs/decisions/records/testing/scripted-engine-in-process-net.md, regenerated docs/decisions/README.md.
Done-when evidence
curlof/api/v1/scripted/playout/build/<id>/contextwithout a header returned 401 on a scripted playout built viascripts/e2e-local.sh, port 8510, 2026-10-30). This comment is the recorded evidence.ErsatzTV.Tests/Filters/ScriptedScheduleAuthorizationTests.cs(dotnet test ErsatzTV.Tests2154 passed).docs/decisions/records/testing/scripted-engine-in-process-net.mdin PR #939.review-verdict/h10status is for the orchestrator to post.