This app lets authorized users execute arbitrary scripts, optionally as a different Windows account, optionally elevated. That's a lot of power concentrated in one place. Read this before deploying it.
Neither ships with a default - the app refuses to start without them so you can't accidentally run with a guessable key.
| Environment variable | Used for | Generate with |
|---|---|---|
SCRIPTRUNNER_MASTER_KEY |
AES-256-GCM encryption of stored account passwords at rest | [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) (PowerShell 7) or openssl rand -base64 32 |
SCRIPTRUNNER_JWT_SIGNING_KEY |
Signs login session tokens | same as above |
Both must decode to at least 32 bytes. Store them the way you'd store any production
secret (a secrets manager, a protected CI variable, DPAPI-protected config, etc.) - not
committed to source control. If you rotate SCRIPTRUNNER_MASTER_KEY, every stored
credential becomes undecryptable and must be re-entered; if you rotate
SCRIPTRUNNER_JWT_SIGNING_KEY, every logged-in session is invalidated.
Don't generate either key with Get-Random (an earlier version of this doc, the
README, and IMasterKeyProvider.cs's own comment suggested exactly that, and have since
been corrected) - Get-Random is a fast, statistically-random PRNG, not a
cryptographically secure one, and both of these values are keys, not data. On Windows
PowerShell 5.1 (rather than PowerShell 7), RandomNumberGenerator.GetBytes isn't
available - use [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
instead (the same fix applied to Install-Service.ps1's own key generation, which runs
under 5.1 as an MSI deferred custom action).
Both keys now have their length enforced in code. AesCredentialProtector's constructor
throws unless SCRIPTRUNNER_MASTER_KEY decodes to exactly 32 bytes (AES-256), and
JwtTokenService's constructor throws unless SCRIPTRUNNER_JWT_SIGNING_KEY decodes to
at least 32 bytes (HMAC-SHA256 doesn't require an exact key length the way AES-256
does, so this is a minimum, not an exact match) or isn't valid base64 at all. Both are
resolved eagerly at startup (see Program.cs's "fail fast" scope block) so a
missing/short/invalid key is caught immediately rather than on first login or first
credential use.
ICredentialProtector/IMasterKeyProvider (in ScriptRunner.Core/Services) are small
interfaces specifically so you can swap EnvironmentMasterKeyProvider for one backed by
Azure Key Vault, AWS KMS, or similar, without touching anything else.
An installed Windows Service (the MSI, or Install-Service.ps1) no longer has a
default admin credential at all in the normal path: first launch shows a web-based
first-run setup wizard (SetupController/SetupWizardPage) where you pick the data
directory and set a real admin username/password yourself - see the root README's
"First run for an installed service". Until that wizard completes,
SetupGateMiddleware blocks the rest of the API (everything except /api/setup/*), so
there's no window where an unconfigured instance with a guessable admin credential is
reachable.
Local development (dotnet run) and scripted/unattended deployments still use the
older, simpler path: if SCRIPTRUNNER_ADMIN_PASSWORD isn't set before the first run
(and the wizard is bypassed - see below), DbSeeder creates the admin account with a
fixed, hardcoded default password (DefaultAdminPassword in
ScriptRunner.Data/Seed/DbSeeder.cs, currently "ScriptRunner"). This is a deliberate
simplicity trade-off for local/dev use, but it means every such instance starts with the
exact same, publicly-known admin credential until someone logs in and changes it - a
classic "known default credentials" exposure if the instance is reachable before that
happens. (Setting SCRIPTRUNNER_ADMIN_USERNAME/SCRIPTRUNNER_ADMIN_PASSWORD before an
installed service's first startup also auto-completes setup at the default data
directory using those values, skipping the wizard entirely - useful for scripted
deployments, but it inherits this same "don't skip SCRIPTRUNNER_ADMIN_PASSWORD"
caveat.)
Mitigate this by doing one of:
- For an installed service, just use the wizard - there's no default password to worry about in that path.
- For local dev or a scripted deployment, set
SCRIPTRUNNER_ADMIN_USERNAME/SCRIPTRUNNER_ADMIN_PASSWORDbefore first run so the default is never used at all. - If you do rely on the default, log in and change the password before exposing the
instance beyond localhost/a trusted admin - a warning is logged and written to
first-run-admin-password.txtnext to the database as a reminder. - Firewall the port to trusted networks/VPN until the password is rotated.
SetupController (GET/POST /api/setup/*) has no [Authorize] on it, on purpose -
there's no admin account (and often no database at all) to authenticate against until
setup finishes. That includes the directory-browsing endpoints
(GET /api/setup/browse, POST /api/setup/create-directory), which let an
unauthenticated caller enumerate directory names and drive letters on the ScriptRunner
host, and create new empty folders anywhere the service account can write. Each of these
endpoints refuses once IDataDirectoryProvider.IsSetupComplete is true (a 409 Conflict), so this window closes permanently the moment setup finishes - but until then,
anyone who can reach the port can browse the server's directory structure and create
folders on it. SetupGateMiddleware blocks every other API endpoint during this
window, so the practical exposure is limited to directory names/structure and empty-folder
creation, not data access - but treat "reachable before setup completes" the same way you
would the default-password window above: firewall the port to trusted networks until an
admin has walked through the wizard, especially on a network you don't fully trust.
A script with SourceMode: NetworkReference (see the root README's
"Network-referenced scripts") has ScriptRunner read a UNC path live at the start of
every run, optionally using a stored credential to authenticate to that share. A few
things worth knowing:
- The network path is only as trustworthy as whoever can write to that share. ScriptRunner executes whatever is on the share at run time, with no diffing, review, or pinning to a known-good version - if the share is writable by more people than the script's own Write-permission list in ScriptRunner, that's a wider blast radius than a Stored script (where changing the content requires ScriptRunner Write access specifically). Restrict write access to the share itself accordingly, the same way you would the ScriptRunner credential store.
- The credential used to read the network path is decrypted the same way run-as
credentials are (
ICredentialProtector/AES-256-GCM - see "The two required secrets" above) and is never logged or returned to the browser. - Impersonation is one-shot and scoped to just the file read, via
LogonUserwithLOGON32_LOGON_NEW_CREDENTIALS+WindowsIdentity.RunImpersonated(seeNetworkScriptContentReader) - deliberately notLOGON32_LOGON_INTERACTIVE(what run-as/elevation uses) and deliberately not a persistent net-use-style session. This means the network credential's account does not need local logon rights on the ScriptRunner host at all (unlike a run-as credential, which does), only read access to the share - a real advantage for locking down that account's blast radius. It also means two network-referenced scripts on different shares can use two different credentials in the same process without Windows's usual one-credential-per-server restriction getting in the way. - This impersonation path has not been exercised on a real Windows machine as part
of building this feature, for the same reason noted throughout this document (see
"This has not been run on a real Windows machine" below) - budget real testing time,
in particular: a share requiring a domain credential vs. a local one, an access-denied
case (confirm it's classified
AccessDeniedand not silently treated asNotFound), and a path that becomes unreachable mid-run (network blip) to confirm it fails the run cleanly rather than hanging.
This is the part of the app most worth scrutinizing. Short version: it's the same
family of Win32 techniques Sysinternals PsExec's -h switch uses, run from a Windows
Service.
-
No credential chosen: the script just runs as whatever account the ScriptRunner process itself runs as (
System.Diagnostics.Process, straightforward). IfRequiresElevationis also set with no run-as account, that's a no-op - there's no meaningful "elevate myself" operation for a non-interactive service process, and the UI/logs say so. -
A credential is chosen, not elevated:
WindowsProcessLaunchercallsLogonUserwith the stored (decrypted) username/password to get a token for that account, thenCreateProcessAsUserto launch the script under that token - with the process's stdout/stderr wired to pipes we own, so output still streams into the UI. (.NET's ownProcessStartInfo.UserName/Passwordcan't do this - it explicitly disallows redirecting output when launching as a different user.) -
A credential is chosen, elevated: if the target account is a UAC "split-token" administrator, Windows keeps a second, fully-elevated token linked to the normal (filtered) one you get back from
LogonUser.GetTokenInformation(..., TokenLinkedToken)retrieves that linked token, and that token is what gets passed toCreateProcessAsUser. This is exactly howpsexec -havoids an interactive UAC prompt. If the account isn't a split-token admin (or UAC is off), there's no linked token to fetch; ScriptRunner logs that and continues with the standard token rather than failing the run.
All of this lives in ScriptRunner.Core/Services/Execution/Windows/NativeMethods.cs and
WindowsProcessLauncher.cs.
This project was built inside a Linux sandbox with no Windows host available, and the sandbox couldn't install the .NET SDK either (no network route to nuget.org/dot.net), so none of this code has been compiled or executed for real. The P/Invoke signatures match the documented Win32 contracts and the overall approach mirrors long-established tools (PsExec, various RMM agents), but "matches the docs" and "actually works on your Windows Server build with your Group Policy" are different claims. Budget real testing time before trusting this with anything sensitive.
Before relying on this in production, on an actual Windows test box:
dotnet buildthe whole solution and fix anything the compiler flags - the code here was written without a compiler in the loop.- Create a local admin test account (a UAC split-token admin, not a domain account, is the simplest first test) and store it as a credential.
- Create a trivial diagnostic script, e.g. a PowerShell script containing just
whoami /allandecho $env:USERPROFILE, and run it:- As the service account (no credential) - confirm output streams live.
- As the test admin account, not elevated - confirm
whoamishows that account, and checkwhoami /groupsshows the filtered token (Administrators group listed as "Deny" or absent from enabled groups). - As the test admin account, elevated - confirm
whoami /groupsnow shows Administrators enabled. This is the case most worth verifying carefully; ifGetTokenInformation(TokenLinkedToken)isn't returning what's expected, this is where it'll show up as "elevation silently did nothing."
- Test with a domain account if your real usage involves domain credentials - domain
logon semantics, profile loading, and
CreateEnvironmentBlockbehavior can differ from local-account testing. - Test what happens when the account/password is wrong (should surface a clear
LogonUser failederror, not a hang) and when a script times out (should show up as a killed process, not an orphan). - Load-test a handful of concurrent runs.
WindowsProcessLauncherserializes the pipe-creation +CreateProcessAsUserstep with a semaphore (to avoid a handle-inheritance race between concurrent launches - see the comment aboveProcessCreationLock), and the process-exit wait currently occupies a thread-pool thread for the life of each run; if you need high concurrency, that wait loop is the first thing to revisit (switch toThreadPool.RegisterWaitForSingleObjectfor a truly async wait, and considerSTARTUPINFOEX+PROC_THREAD_ATTRIBUTE_HANDLE_LISTto remove the serialization). - Specifically exercise
NativeEnvironmentBlock.Merge(see "Batch scripts: cmd.exe argument injection" below) - it's new, hand-rolled Win32 environment-block marshaling that hasn't run on a real machine any more than the rest of this file has:- A Batch script with a couple of declared parameters that just does
echo Name is %Name%(or similar) for each one, run with no run-as account, then again with one - confirm the values come through correctly both ways, and that ordinary profile variables (%USERPROFILE%,%PATH%) are still intact in the run-as case (i.e. the merge didn't drop the rest of the environment). - A batch parameter value containing
%SCRIPTRUNNER_MASTER_KEY%,&,|, or"- as of the 2026-08-31 real-machine test this should now make the run fail immediately withBatchExecutor's "can't safely receive" error, not run at all - see "Batch scripts: cmd.exe argument injection" below for why the earlier "comes through as inert literal text" expectation here was itself wrong. - A batch script parameter name that collides with the reserved list (e.g.
PATH) - confirm the run fails clearly withBatchExecutor's "collides with a reserved environment variable" error rather than silently clobbering something.
- A Batch script with a couple of declared parameters that just does
This was the most serious issue found in a security review of this codebase, and the
first fix for it turned out to be incomplete - caught only because it was actually run
against real cmd.exe rather than trusted on paper. If you take one lesson from this
section: don't consider the second fix below closed either until it's been re-run the same
way.
BatchExecutor used to build a batch script's command line by quoting each parameter
value with WindowsCommandLine.QuoteArgument - correct, standard
CreateProcess/argv-style backslash-and-quote escaping - and running it via
cmd.exe /d /c "<script.bat>" <args>. That quoting scheme is the right one for an
ordinary executable, but cmd.exe does not parse its command line the way normal Win32
programs do: it re-scans the entire line itself, and critically, %VARIABLE%
environment-variable expansion happens as an early, separate pass that is not
suppressed by being inside double quotes. This is a well-documented vulnerability class -
see GMO Flatt Security's
"BatBadBut: you can't securely execute commands on Windows",
which affected the standard library command-execution APIs of Python, Rust, Go, Node.js,
Java, Erlang and others (each assigned its own CVE) for exactly this reason: quoting an
argument correctly for CreateProcess does not make it safe to pass to a .bat/.cmd
file run through cmd.exe.
Concretely, against this codebase: any user with mere Execute permission on a batch
script (not Write - the permission this document elsewhere describes as the safe,
content-can't-be-changed tier) could previously supply a parameter value like
%SCRIPTRUNNER_MASTER_KEY% and have cmd.exe expand it to the real secret before the
script even ran, putting the plaintext key into the child process's command line - and,
per the same research, a single character read out of %CMDCMDLINE% was enough to break
out of the quoting and inject arbitrary additional commands. PowerShellExecutor was
never affected - powershell.exe uses normal Win32 argv parsing, not cmd.exe's
line-rescanning/percent-expansion behavior.
Fix #1 (necessary, not sufficient on its own): BatchExecutor no longer puts
parameter values on the command line at all. Each declared parameter is now delivered to
the script as an environment variable of the same name (%ParamName% from inside the
script, not %1/%2) instead of a positional argument - see
BatchExecutor.BuildLaunchSpec's own doc comment for the full reasoning. That means the
actual cmd.exe /d /c "script.bat" launch line never contains any attacker-controlled
text, so there's nothing left for cmd.exe's expansion/rescanning to act on at launch.
LaunchSpec.EnvironmentVariables carries the values through to WindowsProcessLauncher,
which sets them on ProcessStartInfo.Environment for the no-run-as fast path, and merges
them into the native environment block (on top of, not replacing, the profile's own
environment) for the run-as path via the new NativeEnvironmentBlock.Merge helper.
Parameter names are validated against a strict ^[A-Za-z_][A-Za-z0-9_]*$ pattern and a
reserved-name list (PATH, COMSPEC, SCRIPTRUNNER_MASTER_KEY, etc.) in BatchExecutor
before they ever become a real environment variable name - names come from whoever has
Write access to the script (the same trust tier as script content itself), so this is
defense-in-depth rather than the primary fix.
This alone was tested against real cmd.exe on 2026-08-31 and confirmed insufficient.
The test script (test-scripts/3-batch-injection-fix-test.bat) declares a required
TestValue parameter and does a plain echo %TestValue%. Run with TestValue set to
%SCRIPTRUNNER_MASTER_KEY% & whoami & echo INJECTED_MARKER_XYZ, the actual output was:
%SCRIPTRUNNER_MASTER_KEY%
nt authority\system
INJECTED_MARKER_XYZ
whoami and the second echo both ran as their own commands - full command injection,
under whatever account/privilege that run used. (The master key reference happened to come
back unexpanded in this particular run rather than leaking the real key, but that's
incidental to this run's environment, not something to rely on - see fix #2.) The reason
fix #1 doesn't close this: cmd.exe's %VARIABLE% expansion isn't only a hazard on the
original launch command line - it happens every time cmd.exe reads a line inside the
script too, as a raw textual substitution done before that line is tokenized into
separate commands. So echo %TestValue% inside the .bat file itself is exactly where the
substituted value gets re-interpreted as cmd.exe syntax, regardless of how safely the
variable was populated. ScriptRunner doesn't control how a script author references a
parameter inside their own script content, so this can't be fixed by anything on the
launch side alone.
Fix #2: BatchExecutor now rejects, before starting the process at all, any parameter
value containing a character that's dangerous once re-expanded inside a script's own body:
& | < > ^ " % or a bare CR/LF. The run fails immediately with a clear error naming the
offending parameter, rather than silently letting cmd.exe reinterpret it. This is
necessarily a blunt instrument - the BatBadBut research's own conclusion is that there is
no generally-safe way to quote around this for cmd.exe - so a legitimate value that happens
to need one of these characters (a literal & in a name, say) simply can't be passed to a
Batch script parameter; use a PowerShell script instead for that case, since PowerShell's
parameter binding doesn't re-parse a substituted value as code the same way.
This does change how batch scripts are authored: existing scripts written against the
old %1/%2 positional convention need to be rewritten to read %ParamName% instead.
Since this hasn't shipped to real users yet, now is the right time for that change.
Re-test needed: re-run 3-batch-injection-fix-test.bat with the same payload above
against a rebuilt/restarted service and confirm the run now fails with fix #2's error
message instead of executing anything. Also worth trying a value with just a bare % (no
other metacharacters) to confirm that alone is rejected too, since it's the one most likely
to look "safe" to a future script author.
ExecutionHub.JoinRun(int runId) used to add the caller's SignalR connection to the
"run-{runId}" group with no authorization check of its own - the hub's own doc comment
said access was "enforced by RunHistoryController/ExecutionController before a client is
ever told a run id exists." In practice, runId is a normal EF Core auto-incrementing
integer primary key, which is sequential and therefore guessable/enumerable - it isn't a
secret, and nothing stopped an authenticated user (any authenticated user, not just
Admins) from calling JoinRun with a run ID they were never told about and were never
granted Execute or Write permission on. RunHistoryController correctly filters
non-admins to their own runs, but that only protected the REST history endpoints -
ExecutionHub didn't apply the same ScriptPermissionRequirement/ownership check, so a
low-privileged user could watch another user's script run live (stdout/stderr,
non-secret parameters, exit codes, and anything a script prints - secret-marked
parameters were, and still are, separately redacted by ScriptExecutionOrchestrator, so
those specifically were never exposed by this gap, but everything else in the log stream
was).
The fix: JoinRun now looks up the run's owning user and script, and applies the
same authorization check ExecutionController.Run/Cancel already use (admin,
run owner, or ScriptPermissionRequirement(Execute) on the script) before adding the
caller's connection to the run's SignalR group - a caller without access gets the same
"Run not found or access denied" HubException whether the run id doesn't exist or just
isn't theirs, so the error itself can't be used to enumerate real run ids.
LogonUser, CreateProcessAsUser, and (especially) reading another account's linked
elevated token generally require the caller to hold SeAssignPrimaryTokenPrivilege,
SeIncreaseQuotaPrivilege, and effectively SeTcbPrivilege-level trust. LocalSystem
has all of this by default, which is why it's the simplest choice and what the README's
sc.exe create example implies. If your security policy requires avoiding LocalSystem,
you'll need to explicitly grant the chosen service account the above privileges (Local
Security Policy > User Rights Assignment) and test the elevation path again - don't
assume a lesser account works without verifying it.
Either way, treat the ScriptRunner service account as highly privileged: anyone who can edit scripts with Write access, or who administers the credential store, can effectively run arbitrary code as any stored account. Restrict Admin and Write access accordingly.
- Secret parameters: a script parameter marked "secret" is masked with
********in log output (RedactSecretsinScriptExecutionOrchestrator) by literal string replacement. This catches the common case but not, e.g., a value the script itself re-encodes (base64, URL-encoding) before printing it. Don't rely on it as the only control for highly sensitive values - avoid having scripts echo secrets at all. - Script content is trusted, PowerShell/Batch itself is not sandboxed. Anyone with Write access to a script can make it do anything the run-as account can do. Execute access does not include Write access to the script's content by default - use that distinction deliberately.
- JWTs are bearer tokens with an 8-hour default expiry (
Jwt:ExpiryMinutes) and no refresh-token flow. A stolen token is valid until it expires; lower the expiry if that's a concern for your environment, or add refresh tokens/short-lived access tokens if you need tighter control. - CORS is only opened up for
http://localhost:5173(the Vite dev server) and only in the Development environment. Serve the built frontend from the same origin as the API in production (or add its origin to the CORS policy) rather than widening this. - SQLite is fine for a single-instance internal tool but gives you no built-in encryption-at-rest for the database file itself (only the credential passwords inside it are separately encrypted via AES-GCM). Protect the file with filesystem permissions/BitLocker if that matters for your environment.
- Fixed: the data directory path is now validated against connection-string special
characters.
DataDirectoryProvider.ConnectionStringbuilds"Data Source={dataDirectory}\scriptrunner.db"by plain string interpolation, and a data directory containing a semicolon (a legal character in a Windows folder name) would have injected extraMicrosoft.Data.Sqliteconnection-string keywords instead of being taken as a literal path segment. Low severity in practice - the only people who could set this value are whoever can reach the pre-setup wizard (already a documented trust boundary above) or an admin editingappsettings/the service's environment directly - but cheap to close, soDataDirectoryProvider.CompleteSetupnow rejects any data directory containing;alongside its existing absolute-path check.