8

I need to pass a password with special characters from powershell script automation.ps1 to batch script batch_script.bat which pipes it to main.py. Piping from batch_script.bat to main.py works fine, that is authentication succeeds. However, when I run the entire procedure described above, authentication fails, but echoing the password shows the correct password string.

My guess is that there are issues with special characters. What is a safe way to pass these strings?

Background

I want to automate the daily download from some external source via a Python script main.py. This process requires a password. So I wrote a batch_script.bat which pipes the password to the Python script when prompted for it. However, I don't want to store the password as plain text in the batch script, so I encrypted the password and wrote another layer automation.ps1 which decrypts the password and passes it as plain text to batch_script.bat.

automation.ps1

# get password
$securePassword = Get-Content "<file_path>" | ConvertTo-SecureString
$credentials = New-Object System.Management.Automation.PsCredential("<user_name>",$securePassword)
$unsecurePassword = ($credentials).GetNetworkCredential().Password

# call script
$script = "<path>\batch_script.bat"
$args = @("`"<user name>`"","`"$unsecurePassword`"")
start-process $script $args

batch_script.bat

(I am aware that in this example I discard the passed username, just wanted to preserve the fact that I pass multiple arguments in case there is any relevance to it)

@echo off
SET username=%~1
SET password=%~2
echo(%password%|python main.py
Jhonny
  • 568
  • 1
  • 9
  • 26
  • 3
    Why not use either Poweshell or Python for the whole thing, and ditch the batch entirely? Both Powershell and Python are capable to, well, anything. – vonPryz May 21 '20 at 21:28
  • Not only Python because I have to put the entire process in Windows Scheduler, but need to activate the conda environment before executing the Python script. I could get rid of the intermediate step, but only after I finished the batch script we decided to encrypt the password. Now, I don't have the time to translate the batch code to powershell. It is a little more complex since it prepares arguments and passes them to the Python script (not included here for brevity). This is the first time I touch Batch/Powershell, so I am not as productive as I want to be. – Jhonny May 21 '20 at 21:38
  • As an aside: `$args` is an [automatic variable](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Automatic_Variables#args), so you should avoid its use as a custom variable. – mklement0 May 23 '20 at 17:41
  • The answer you've accepted does nothing different than the code in your question, given that the `-ArgumentList` parameter is _implied_ for the second _positional_ argument; that is, `start-process $script $args` and `saps "$script" -argumentlist $args` are equivalent (`saps` is a built-in alias for `Start-Process`; the double quotes around `$script` are redundant). – mklement0 May 23 '20 at 17:44

3 Answers3

5

With following, all special characters should be handled very well. If any character required to be escaped, check this

$pass could be any string but check for special characters of powershell

$pass="%^&<>|'\``,;=(\)![]\/";
# Wait till it ends with -Wait when using -NoNewWindow.
# It may be comprehensible to use `" instead of "" to denote we are enclosing string in quotes.
(thanks @mklement0 for elaboration).
start-process -Wait -NoNewWindow .\script.cmd "`"$pass`""
script.cmd
    setlocal

    rem Remove Double quotes
    set "arg=%~1"

    rem Test result with base64 encoding
    echo|set/p="%arg%"|openssl base64
    rem echo is used with set/p to prevent trailing new line.
    echo|set/p="%arg%"|python main.py

    rem Test with following, argument is in double quotes
    rem script "%^&<>|'\`,;=(\)![]\/"
    rem Expected result
    rem %^&<>|'\`,;=(\)![]\/



subcoder
  • 636
  • 8
  • 15
  • +1 for the `set/p=` workaround; slight simplification: ` – mklement0 May 23 '20 at 20:10
  • 1
    Also worth noting that if you use `-NoNewWindow` without `-Wait`, any output from the batch file invoked will arrive _asynchronously_, probably causing confusion. Conversely: invoking a batch file via `Start-Process` is probably the wrong approach to begin with. `"""$pass"""` is effectively the same as `"\`"$unsecurePassword\`""` from the question (difference in variable names aside): while PowerShell's general-purpose escape character is `\``, `"` in particular can alternatively be escaped as `""` inside a `"..."` string. – mklement0 May 23 '20 at 22:41
  • 1
    As for how escaping of special characters (metacharacters) fits into the picture: on the PowerShell side, nothing needs to be escaped (if you leave the unfortunate requirement to manually escape embedded `"` characters when calling external programs aside - see [this answer](https://stackoverflow.com/a/59036879/45375)). On the `cmd.exe` (batch-file) side: by enclosing the argument in the PowerShell call in `"..."`, the argument is passed safely. Using your `set /p` workaround to print the argument _unquoted_ also makes escaping inside the batch file unnecessary. – mklement0 May 23 '20 at 23:04
4

tl;dr:

  • Unless you specifically need the batch file to run in a new window, avoid Start-Process (whose built-in aliases are start and saps), and invoke the batch file directly.

  • To avoid problems with special characters in $unsecurePassword, do not pass it as an argument, pass it via stdin (the standard input stream), which your batch file will pass through to your python script:

automation.ps1:

# ...
$script = "<path>\batch_script.bat"
# Pass the password via *stdin*
$unsecurePassword | & $script 'userName'

Note: It is the $OutputEncoding preference variable that controls what character encoding PowerShell uses for sending text to an external program's stdin. In Windows PowerShell, that variable defaults to ASCII(!) encoding, meaning that any characters outside the 7-bit ASCII-range of Unicode characters, such as accented characters, are unsupported (they turn to literal ?); fortunately, PowerShell [Core] v6+ now defaults to UTF-8. Assign the required encoding to $OutputEncoding as needed.

batch_script.bat:

@echo off

SET username=%~1

REM The batch file will pass its own stdin input through to Python.
python main.py

Read on for background information.


Invoking a batch file from PowerShell:

Unless you truly need to launch a batch file in a new window, the best approach is to invoke it directly from PowerShell; that way, it runs:

  • in the same console window, synchronously.

  • with its output streams connected to PowerShell's (which allows you to capture or redirect the output).

Because your batch-file path is stored in a variable, direct invocation requires use of &, the call operator:

# Note: The " chars. around $unsecurePassword are only needed if the variable
#       value contains cmd.exe metacharacters - see next section.
& $script 'userA' `"$unsecurePassword`"

Start-Process is usually the wrong tool for invoking console applications, batch files, and other console-based scripts; see this answer for more information.

If you do need the batch file to run in a new window (which is only an option on Windows), use Start-Process as follows (the command will execute asynchronously, unless you also pass -Wait):

# The string with the arguments to pass is implicitly bound 
# to the -ArgumentList parameter. Use only " for embedded quoting.
Start-Process $script "userA `"$unsecurePassword`""

Note: While the (implied) -ArgumentList (-Args) parameter is array-valued ([string[]]) and passing the arguments individually is arguably the cleaner approach, this generally does not work properly, due to a longstanding bug that probably won't get fixed; for instance,
Start-Process foo.exe -Args 'one', 'two (2)' passes 3 arguments rather than 2; that is, it passes single string 'two (2)' as two arguments - see this GitHub issue.
Therefore, it is ultimately simpler and more predictable to pass a single argument with embedded quoting to -ArgumentList, but be sure to use only " (not ') for embedded quoting:
Start-Process foo.exe -Args "one `"two (2)`""


Passing arguments robustly to cmd.exe / batch files:

Note:

  • The limitations of cmd.exe (the legacy command processor that interprets batch files) prevent fully robust solutions; notably, you cannot prevent the interpretation of tokens such as %foo% as environment-variable references when you call a batch file from PowerShell (at least not without altering the argument to %foo^%, which will retain the ^).

  • In your specific case, since you're trying to echo an argument unquoted, embedded double quotes (") in such an argument - which need to be escaped as "" - aren't properly supported: they are passed through as "".

Passing an unquoted argument to cmd.exe / a batch file breaks, if that argument contains one of cmd.exe's metacharacters, i.e., characters with special syntactic meaning; in this context, they are: & | < > ^ "

The solution is to enclose the argument in double quotes ("..."), with the added need to double " chars. that are embedded (a part of the value).

PowerShell, after performing its own parsing of the command line (notably evaluating variable references and expressions), constructs the command line that is ultimately used to invoke the external target program, behind the scenes.

However, it only automatically double-quotes an argument if it contains spaces, not if it only contains cmd.exe metacharacters; e.g., a variable with verbatim string content two (2) is passed double-quoted - $val = 'two 2'; .\foo.bat $val results in command line .\foo.bat "two 2" - whereas string content a&b is not - $val = 'a&b'.\foo.bat $val results in .\foo.bat a&b - which breaks.

The solution - as shown in your question - is to enclose the variable reference in literal, embedded " characters, because such a "pre-quoted" value instructs PowerShell to pass the value as-is:
$val = 'a&b'; .\foo.bat `"$val`" results in .\foo.bat "a&b"

Note: .\foo.bat "`"$val`"" has the same effect; I'm taking advantage of the fact that PowerShell in argument (parsing) mode (generally) implicitly treats arguments as if they were double-quoted; in expression (parsing) mode, such as in the array-construction statement in the question (@(..., ...)), you do need the "`"$val`"" form.


The problem with your specific batch file:

A properly "..."-enclosed argument (with any embedded " chars. escaped as "") is properly seen as a parameter (e.g., %1) inside a batch file.

However, it is seen with the enclosing double quotes and with any doubled embedded " chars.

If you were to pass this parameter to the target program (python in this case) as an argument, everything would work as expected.

However, since you're passing the value via stdin using echo, you need to strip the enclosing double quotes so that they're not passed as part of the value, which is what your batch file attempts (e.g., %~2)

However, passing the stripped value causes the echo command to break.

There is no good solution to this problem with echo, short of performing cumbersome explicit ^-escaping (^ being cmd.exe's escape character):

$escapedUnsecurePassword = $unsecurePassword -replace '[&|<>^]' -replace '"', '""'
& $script 'userA' `"$escapedUnsecurePassword`"

That alone still isn't enough, however - your batch_script.bat file needs a modification too: Because the assignment itself in your SET password=%~2 command isn't protected with double quotes, it breaks with values that contain metacharacters; somewhat paradoxically, you must use the form SET "password=%~2" in order to safely strip the embedded enclosing " chars.:

@echo off

REM Strip the enclosing "..." from the arguments (%~<n>)
REM !! The *assignment itself* must be in "..."  so that
REM !! it does not break if the value has cmd.exe metacharacters.
set "username=%~1"
set "password=%~2"

echo(%password%|python main.py

Note that that will work as intended for all metacharacters except the - of necessity doubled - embedded ", which are passed through as "".


However, there is a workaround for echoing a string with metacharacters unquoted, as also demonstrated in subcoder's helpful answer:

If you define batch_script.bat as follows:

@echo off

set "username=%~1"

REM Do NOT strip quotes from the password argument
set password=%2

REM Using a trick with set /p, echo the password unquoted.
REM Note: Put the "|" directly after the ":" to avoid trailing spaces.
<NUL set /p=%password% & echo:|python main.py

The workaround repurposes set's /p option, which accepts a prompt message to print when interactively prompting the user for a value and prints the message without quotes; the actual interactive prompt is suppressed via <NUL, so that only the message is printed.

echo: prints a newline (line break), which is necessary, because the set /p command prints its message without a trailing newline (if you don't want to send a newline, simply omit & echo:).

Caveat: In addition to the problem with embedded "" applying here too, this workaround has a side effect: it trims leading whitespace; e.g., " foo " results in output foo   (only trailing whitespace is preserved); however, given that arguments with leading whitespace are rare, this may not matter in practice.


Given how cumbersome / obscure the above approaches are, the stdin-based approach shown at the top is preferable.

mklement0
  • 382,024
  • 64
  • 607
  • 775
1

You pass arguments to batch files in powershell using the -argumentlist switch of start/saps. For you you could use:

saps "$script" -argumentlist $args

But I would suggest first breaking $args up as it may not work since to pass arguments, you usually want to pass the arguments one at a time like:

saps "$script" -argumentlist "1","2","3"

Passing $args will work most of the time, but there are some cases where where it won't work. Most of the time you are fine though

Nico Nekoru
  • 2,840
  • 2
  • 17
  • 38
  • 2
    Taking a closer look: Given that the `-ArgumentList` parameter is _implied_ for the second _positional_ argument, your solution is effectively the same as the OP's own attempt, `start-process $script $args` (leaving aside that the double quotes around `$script` in your solution are redundant). Aside from that, there is no difference between passing an _array_ of arguments via a variable (`$args`) vs. via a _literal_ array (`"1","2","3"`). – mklement0 May 23 '20 at 18:42
  • 2
    While passing arguments as the elements of an array _should_ be the most robust approach, sadly, it is not, due to a longstanding bug: see [this GitHub issue](https://github.com/PowerShell/PowerShell/issues/5576). It is ultimately simpler to pass the arguments as a _single string_, with embedded doube-quoting as needed; e.g., instead of `Start-Process $script 'one', 'two (2)'`, use `Start-Process $script "one \`"two (2)\`""` – mklement0 May 23 '20 at 18:42