4

Imagine the following code:

# Script Start
$WelcomeMessage = "Hello $UserName, today is $($Date.DayOfWeek)"

..
..
# 100 lines of other functions and what not...
..

function Greet-User
{
    $Username = Get-UserNameFromSomewhereFancy
    $Date = Get-DateFromSomewhereFancy

    $WelcomeMessage
}

This is a very basic example, but what it tries to show is a script where there is a $WelcomeMessage that the person running the script can set at the top of the script and controls how/what the message displayed is.

First thing's first: why do something like this? Well, if you're passing your script around to multiple people, they might want different messages. Maybe they don't like $($Date.DayOfWeek) and want to get the full date. Maybe they don't want to show the username, whatever.

Second, why put it at the top of the script? Simplicity. If you have 1000 lines in your script and messages like these spread all over the script, it makes it a nightmare for people to find and change these messages. We already do that for static messages, in the form of localized strings and stuff, so this is nothing new, except for the variable parts in it.

So, now to the issue. If you run that code and invoke Greet-User (assuming the functions/cmdlets for retrieving username and date actually exist and return something proper...) Greet-User will always return Hello , today is.

This is because the string is expanded when you declare it, at the top of the script, when neither $UserName nor $Date objects have a value.

A potential workaround would be to create the strings with single quotes, and use Invoke-Expression to expand them. But because of the spaces, that gets a bit messy. I.e.:

$WelcomeMessage = 'Hello $env:USERNAME'
Invoke-Expression $WelcomeMessage

This throws an error because of the space, to get it to work properly it would have to be declared as such:

$WelcomeMessage = 'Hello $env:USERNAME'
$InvokeExpression = "`"$WelcomeMessage`""

Messy...

Also, there's another problem in the form of code injection. Since we're allowing the user to write their own welcome message with no bounds specified, what's to prevent them from putting in something like...

$WelcomeMessage 'Hello $([void] (Remove-Item C:\Windows -Force -Recurse))'

(Yes, I know this will not delete everything but it is an example)

Granted this is a script and if they can modify that string they can also modify everything else on the script, but whereas the example I gave was someone maliciously taking advantage of the nature of the script, it can also happen that someone accidentally puts something in the string that ends up having unwanted consequences.

So... there's got to be a better way without the use of Invoke-Expression, I just can't quite thing of one so help would be appreciated :)

JohnUbuntu
  • 699
  • 6
  • 19
  • 1
    Do you need complete expression-like freedom for the contents of `$WelcomeMessage`? Or do you just need to let them control formatting/order/etc. around the known fields? Could you use `"Hello {0}, today is {1:ddd}"` or something as the default and then use `$WelcomeMessage -f $Username,$Date` in the function? – Etan Reisner Jul 14 '15 at 22:10
  • 2
    The outer quotes on the single-quote version appear to be unnecessary That is `Invoke-Expression \`"$WelcomeMessage\`"` appears to work here too (not that that's much better). – Etan Reisner Jul 14 '15 at 22:26
  • Why not make that a parameter of the script, with "Hello $UserName, today is $($Date.DayOfWeek)" as the default value? Then they can change it to whatever they want at invocation without messing with the script at all. – mjolinor Jul 14 '15 at 22:38
  • @EtanReisner Do need complete freedom unfortunately. As I said, some users might not want the DayOfWeek property but instead the full date, or maybe no date at all (as an example) – JohnUbuntu Jul 14 '15 at 22:39
  • @mjolinor because that would suffer from the same issue of the parameter being expanded before the variables have their value. In short, it won't work. And even if you were to use that, it would still allow code injection. – JohnUbuntu Jul 14 '15 at 22:54
  • It will not be expanded if you make the parameter type [scriptblock], and use the invocation operator (&) to run it inside the script. Giving them "complete freedom", and preventing code injection appear to be mutually exclusive objectives, but even if you could prevent code injection you aren't going to stop them from doing anything they couldn't do outright without this script if they want to. – mjolinor Jul 14 '15 at 23:15
  • That depends, what if the script uses delegation/impersonation, creates a scheduled task or something else to elevate certain commands? The user might not have rights to do that himself, but 'force' the script to do it. An even easier example: what if the script is what gets run by a scheduled task running as system? User might have permissions to change the script, but not to perform some of the actions that the system account has rights to do. – JohnUbuntu Jul 14 '15 at 23:28
  • @JohnUbuntu - The scenario presented is "passing your script around to multiple people". If you're doing that or if they have permission to edit the script as a scheduled task then there's nothing you can do that they can't undo. The only method I can think of that might work for a scheduled task would be use a Data Section external to the script that is imported at runtime. Then you can control what variables and cmdlets they're allowed to use in that Data Section from inside the script, and they can't override those restrictions. – mjolinor Jul 15 '15 at 00:06
  • ps. see also: http://stackoverflow.com/questions/15168705/how-to-delay-interpolation-of-variables-in-powershell-strings – JohnLBevan Jul 15 '15 at 00:38
  • 1
    All great answers here. I don't know if you are going to find what you are looking for given your specifications. If the users have access to the script they can do whatever they want anyway as they will already have the permissions to do so. You users would need to have some coding experience in order to be playing around anyway. If not then I would suggest you use script parameters like thors hammer suggests – Matt Jul 15 '15 at 00:39
  • @JohnLBevan Interesting that they are very similar to all the answers we have here! – Matt Jul 15 '15 at 00:40
  • This is difficult... You want a solution that's completely dynamic, immune to code injection, yet within the grasp of a novice. Good luck. – Walter Mitty Jul 15 '15 at 08:33
  • This whole discussion reminds me of discussions surrounding the funarg problem in years gone by. Maybe some other people have grappled with this one in the context of some other language. – Walter Mitty Jul 15 '15 at 08:35

3 Answers3

4

Embedding variables into strings is not the only way to create dynamic text, the way I would do it is like this:

$WelcomeMessage = 'Hello {0}, today is {1}'

# 100 lines of other functions and what not...

function Greet-User
{
    $Username = Get-UserNameFromSomewhereFancy
    $Date = Get-DateFromSomewhereFancy

    $WelcomeMessage -f $Username, $Date
}
Dave Sexton
  • 10,768
  • 3
  • 42
  • 56
  • Nice, but that is less readable by the user (e.g. they have to know that {0} is username) and removes the ability to select properties from the objects on the welcome message (e.g. `.DayOfWeek`); I assume that's why @JohnUbuntu's taking this approach instead... – JohnLBevan Jul 14 '15 at 22:17
  • They have to know that `$user` is supposed to be the username also (it could be anything as variable names are entirely unreliable in general) they also have to know that it will be available in the (random) context where the message is going to be used. A documented set of format arguments to the string is, arguably, more user-friendly since it explicitly defines the contract. – Etan Reisner Jul 14 '15 at 22:25
  • If going down this road I'd much rather use something like: `"Hello [Name.ToUpper()], today is [Date.DayOfWeek]"`, that way there are no actual variables, just a fixed set of names that I then bind to objects. Easy enough to document, still provides full control over the properties/methods of said objects, prevents code injection (if they tried `"Hello [Remove-Item C:\Windows -Force -Recurse]"` the string inside [] is not recognized as a valid variable so nothing happens. Could use regex for replacing for example, making it rather effective. – JohnUbuntu Jul 14 '15 at 22:51
  • @EtanReisner I like your solution and how you defended it, it's not that different from my comment above, except that using names instead of integers might be easier for users (but more work to code). However, your solution doesn't answer the bit about users being able to use properties/methods of those objects. So for example .DayOfWeek, which would have to be defined on the `$WelcomeMessage` itself – JohnUbuntu Jul 14 '15 at 22:57
3

The canonical way to delay evaluation of expressions/variables in strings is to define them as single-quoted strings and use $ExecutionContext.InvokeCommand.ExpandString() later on.

Demonstration:

PS C:\> $s = '$env:COMPUTERNAME'
PS C:\> $s
$env:COMPUTERNAME
PS C:\> $ExecutionContext.InvokeCommand.ExpandString($s)
FOO

Applied to your sample code:

$WelcomeMessage = 'Hello $UserName, today is $($Date.DayOfWeek)'

...
...
...

function Greet-User {
  $Username = Get-UserNameFromSomewhereFancy
  $Date = Get-DateFromSomewhereFancy

  $ExecutionContext.InvokeCommand.ExpandString($WelcomeMessage)
}
Ansgar Wiechers
  • 193,178
  • 25
  • 254
  • 328
  • It looks better than using the Invoke-Expression cmdlet, I'll give you that, but it suffers from the same problems in regards to code injection, be it malicious or accidental. – JohnUbuntu Jul 14 '15 at 22:40
  • 1
    Umm... yes. How do you expect to avoid this problem if you want to allow users to define strings with arbitrary code/variables? – Ansgar Wiechers Jul 14 '15 at 22:45
  • Look at my comment on the answer above as an example of a possible solution. – JohnUbuntu Jul 14 '15 at 22:55
  • 2
    That basically means to not allow arbitrary code/variables, which contraticts your requirement of "complete freedom". – Ansgar Wiechers Jul 14 '15 at 23:13
  • By complete freedom I meant being able to access any variables that exist when the string is expanded, as well as any methods/properties those variables have. However they should not be able to invoke new expressions. – JohnUbuntu Jul 15 '15 at 01:00
  • 2
    Accessing a method could easily be used for code injection e.g. `$host.Runspace.CreateNestedPipeline('Get-Date', $false).Invoke()`. `$host` always exists. – Keith Hill Jul 15 '15 at 01:54
  • Ok, you're right, it was my fault as I didn't clarify this, but by *any variables that exist when the string is expanded*, I meant a subset of variables that *I* choose to make available. So $host would not be one of them for example. – JohnUbuntu Jul 15 '15 at 22:35
2

Have you considered using a lambda expression; i.e. instead of defining the variable as a string value define it as a function, then invoke that function passing the relevant parameters at runtime.

$WelcomeMessage = {param($UserName,$Date);"Hello $UserName, today is $($Date.DayOfWeek) $([void](remove-item c:\test\test.txt))"}

#...
# 100 lines of other functions and what not...
#...

"testfile" >> c:\test\test.txt #ensure we have a test file to be deleted

function Get-UserNameFromSomewhereFancy(){return "myUsername";}
function Get-DateFromSomewhereFancy(){return (get-date);}

function Greet-User
{
    $Username = Get-UserNameFromSomewhereFancy
    $Date = Get-DateFromSomewhereFancy

    $WelcomeMessage.invoke($username,$date)
}

cls
Greet-User

Update

If you only wish to allow variable replacement the below code would do the trick; but this fails to do more advanced functions (e.g. .DayOfWeek)

$WelcomeMessage = 'Hello $Username, today is $($Date.DayOfWeek) $([void](remove-item c:\test\test.txt))'
#...
# 100 lines of other functions and what not...
#...

"testfile" >> c:\test\test.txt #ensure we have a test file to be deleted

function Get-UserNameFromSomewhereFancy(){return "myUsername";}
function Get-DateFromSomewhereFancy(){return (get-date);}
function Resolve-WelcomeMessage(){
    write-output {param($UserName,$Date);"$WelcomeMessage";}
}
function Greet-User
{
    $Username = Get-UserNameFromSomewhereFancy
    $Date = Get-DateFromSomewhereFancy
    $temp = $WelcomeMessage 
    get-variable | ?{@('$','?','^') -notcontains $_.Name} | sort name -Descending | %{
        $temp  = $temp -replace ("\`${0}" -f $_.name),$_.value
    }
    $temp 
}

cls
Greet-User

Update

To avoid code injection this makes use of -whatif; that will only help where the injected code supports the whatif functionality, but hopefully better than nothing...

Also the code now doesn't require parameters to be declared; but just takes those variables which are available at the time of execution.

$WelcomeMessage = {"Hello $Username, today is $($Date.DayOfWeek) $([void](remove-item c:\test\test.txt))"}

#...
# 100 lines of other functions and what not...
#...

function Get-UserNameFromSomewhereFancy(){return "myUsername";}
function Get-DateFromSomewhereFancy(){return (get-date);}
function Resolve-WelcomeMessage(){
    write-output {param($UserName,$Date);"$WelcomeMessage";}
}

"testfile" >> c:\test\test.txt #ensure we have a test file to be deleted

function Greet-User {
    [cmdletbinding(SupportsShouldProcess=$True)]
    param()
    begin {$original = $WhatIfPreference; $WhatIfPreference = $true;}
    process {
        $Username = Get-UserNameFromSomewhereFancy
        $Date = Get-DateFromSomewhereFancy
        & $WelcomeMessage 
    }
    end {$WhatIfPreference = $original;}
}

cls
Greet-User
JohnLBevan
  • 22,735
  • 13
  • 96
  • 178
  • 1
    Couple of problems: too complicated for 'normal' users that just want to modify a string and not have to think about creating a function and worrying about whether that function works properly or not... and it means that you're assuming that both $username and $date are always being displayed in the string (or at least passed to the function. There is always the possibility that the user wants the message to just be "Hello $Username" and not use $Date at all, but you'd still need to declare $Date as a parameter in your function... That, and user can still accidentally (or not) inject code – JohnUbuntu Jul 14 '15 at 22:43
  • Agreed on complexity issue. – JohnLBevan Jul 15 '15 at 00:03
  • With regards to code injection I think there's a contradition in requirements; i.e. you want to be able to run any code (so as to get the output), but also not run any code (to avoid side effects); so how do you determine which code should be allowed to be executed? – JohnLBevan Jul 15 '15 at 00:03
  • With regards to variable availability, do you mean that if `$date` isn't supplied you'd want to see `Hello myUsername` or `Hello myUsername, today is `; as the former would require more sophisticated logic to know to remove some of the fixed string when the date variable isn't available as well as not providing the variable itself. – JohnLBevan Jul 15 '15 at 00:06
  • ps. sadly it looks like `-whatif` outputs to the host stream; so there's no easy way to block those messages... – JohnLBevan Jul 15 '15 at 01:56