A scripted playout's program calls back into ScriptedScheduleController,
which is under the fail-closed global ApiAuthorizationFilter, but was
handed no credential. Reproduced 2026-10-29 on a live local instance at
origin/main e919b925f (scripts/e2e-local.sh, port 8510): the program's
GET .../context and POST .../add_collection both returned 401, and the
playout's buildStatus still read success=true with no items, because a
program that exits 0 after the failure (entrypoint.py catches
ApiException and does) looks like a clean build.
Fix: ScriptedPlayoutBuilderService.StartSession mints a 256-bit token per
build, independent of the build id; ScriptedPlayoutBuilder passes it in
ETV_SCRIPTED_BUILD_TOKEN (environment, not argv: argv is logged at INF
and a fourth positional would shift the user's own arguments);
ApiAuthorizationFilter accepts it as X-Scripted-Build-Token on endpoints
carrying [AcceptsScriptedBuildToken] (ScriptedScheduleController only),
for the build named by the buildId route value, until EndSession.
entrypoint.py sends it as a default header. [SkipApiAuthorization] on the
controller was rejected: it widens the auth-exempt set by a mutating
controller whose only remaining gate would be a logged GUID, and the
Startup host check does not make the endpoint localhost-only.
Tests: ScriptedScheduleAuthorizationTests drives the real filter over the
endpoint metadata MVC builds from the production Startup for all 28
actions; ScriptedBuildTokenTests covers minting/validation and the
launch environment. Clause mutations, each rebuilt and run 2026-10-29:
- drop [AcceptsScriptedBuildToken] from the controller -> red:
Running_Builds_Own_Token_Is_Accepted..., Scripted_Controller_Is_Gated...
- disarm the filter's token clause (no return) -> red: Running_Builds_Own_Token...
- delete the filter's marker check -> red: Token_Opens_Nothing_Outside...
- add [SkipApiAuthorization] to the controller -> red: No_Credential...,
Scripted_Controller_Is_Gated..., Token_Of_Another..., Token_Is_A_401_Once...,
Wrong_Or_Empty_Token (x2), ApiControllerSecurityTests.Only_Scanner_And_Auth...
- token comparison always true -> red: Token_Of_Another..., Wrong_Or_Empty("not-the-token")
- drop WithEnvironmentVariables from CreateCommand -> red: Launch_Hands_The_Token...
- RouteValueName "buildId" -> "id" -> red: Token_Route_Value_Is_The_Controllers...
Live E2E on the fixed build (port 8510, fresh config): the real
entrypoint.py with the generated etv_client copied from the
ersatztv-test image built 3 playout items from a scanned collection;
as the program, own token -> 200 on GET context and POST add_collection;
no header, wrong token, another build's id, and the token on
GET /api/v1/channels and POST /api/v1/collections -> 401; the token
after the build ended -> 401; token absent from the server log.
Docs: new record api.scripted-build-token; testing.scripted-engine-in-
process-net's 401 clause now records the outcome; api-conventions §9 and
testing.md updated; catalog regenerated. OpenAPI regenerated: no drift.
Fixes #913
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
5.2 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| api.scripted-build-token | 2026-10-29 — A scripted playout's program authenticates its callbacks with a build-scoped token, not by exempting the callback controller from API auth (#913) | active | 2026-10-29 | none | none | `ScriptedScheduleController` (`/api/v1/scripted/playout/build/{buildId:guid}`) stays under the global `ApiAuthorizationFilter` and is NOT `[SkipApiAuthorization]`. It carries `[AcceptsScriptedBuildToken]`, which lets the filter accept one more credential on that controller alone: the token `ScriptedPlayoutBuilderService.StartSession` mints for each build (256 bits from the CSPRNG, independent of the build id), presented in the `X-Scripted-Build-Token` header, compared in constant time, and valid only for the build named by the `buildId` route value and only until `EndSession`. `ScriptedPlayoutBuilder` hands it to the program in the `ETV_SCRIPTED_BUILD_TOKEN` environment variable — never on the command line, which is logged and visible in the process table, and whose positional contract (host, build id, mode, then the user's own arguments) is unchanged. The machine key and a session are still accepted there; a request carrying none of the three is a 401. The token is a custom header, so like the machine key it needs no `X-CSRF`. | scripted playout build 401 · scripted script gets no credential · build-scoped credential · ETV_SCRIPTED_BUILD_TOKEN · X-Scripted-Build-Token · AcceptsScriptedBuildToken · why not SkipApiAuthorization on the scripted controller · scripted build silently empty · paths: `ErsatzTV/Filters/ApiAuthorizationFilter.cs`, `ErsatzTV/Filters/AcceptsScriptedBuildTokenAttribute.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilderService.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `scripts/scripted-schedules/entrypoint.py` · issues: #913, #563, #285, #197 | `docs/api-conventions.md` §9 (the credential list); pinned by `ErsatzTV.Tests/Filters/ScriptedScheduleAuthorizationTests.cs` (the filter decision over the endpoint metadata MVC builds from the production `Startup`) and `ErsatzTV.Core.Tests/Scheduling/ScriptedScheduling/ScriptedBuildTokenTests.cs` (minting, validation, and the launch environment) |
The defect this settles. The program a scripted playout runs calls back into
ScriptedScheduleController over http://localhost:{UiPort}, and that controller has been under the
fail-closed global filter since security.fail-closed-api-auth. The program was given no credential, so
every callback was a 401. A program that exits 0 after that failure — entrypoint.py catches the
client's ApiException and does — leaves a build that reports success with no items. Reproduced against
a live instance on 2026-10-29; the evidence is on #913.
Why not [SkipApiAuthorization], the one-line fix. It would restore upstream behaviour for every
existing script, but it widens the auth-exempt set (ApiControllerSecurityTests pins it at the scanner
and auth controllers) by a whole MUTATING controller, and what would then stand in for the credential is
weaker than it looks:
- The host check in
Startupdoes not make the endpoint localhost-only: a request onSettings.UiPortpasses it from any LAN peer.[LocalhostOnly], the scanner's mitigation, readsRemoteIpAddress, which the default trust-allForwardedHeaderslets any peer set withX-Forwarded-For(security.session-auth-dual-credential). - The remaining gate would be the build id, which is logged at Information level on every build and is in the program's argv. #285 already rejected a GUID as the sole gate for the scanner callback.
Why not the machine key. Handing the program X-Api-Key would work with no filter change, and the
program, which runs as the server's own user, could read /config/api.key anyway. It was rejected
because a leak is unbounded: a key that a user script echoes to stdout is the whole API for as long as
the key lives. A leaked build token opens one in-memory build's callbacks for at most the build timeout.
Why the environment and not a fourth argument. Arguments after the mode belong to the user: the fork's own driver takes its script module name there. Inserting a positional would shift them by one and break every existing script, and the argument list is the thing the server logs.
What a script must now do. Read ETV_SCRIPTED_BUILD_TOKEN and send it as X-Scripted-Build-Token
on every callback. entrypoint.py does this with ApiClient.set_default_header, because the
scripted-schedule OpenAPI document declares no security scheme, so the generated etv_client has no
setting for it. A third-party script that ignores the variable gets the same 401 it got before this
record; that compatibility cost is accepted, since that script never worked against this fork either.
Residue, named. The filter decision is tested in-process against the real MVC metadata; the Kestrel
hop and the Cli.Wrap launch remain outside the automated suite (testing.scripted-engine-in-process-net),
so ScriptedPlayoutBuilder.Build reaching CreateCommand, the one place the variable is set, is held by
the live run on #913 rather than by a test.