A re-brand of the Linx project using an ASP.NET Core wrapper as a bridge between IIS and PowerShell.
An on-prem web site for web links, based on RestPS.
- Overview
- Features
- Architecture
- Installation
- Configuration
- Security
- PowerShell Backend
- Services & Middleware
- Dynamic HTML Variables
- Examples
- Troubleshooting
- Development
- Screenshots
A strong recommendation is always to test new things in a lab environment, if such exist, first hand ;) The information provided here is 'as is' until otherwise mentioned!
Ever felt confused when joining a new firm about where to find things? Some things are known by him, her, that and/or maybe even this...
No worries, now if you have some Windows server just idling you can quite easily gather all these pieces in one place and share the knowledge with others.
This place would in this scenario be, you've probably already guessed it, Linx.
- Enable/disable use of personal themes (3 different themes available)
- Enable/disable use of personal links/categories
- Categorize, tag and filter links to find them fast
- Role-based Access Control per link
- Role-based Access Control for editors and admins
- OU-based access control for regular users
- Customize regular expressions used to your need
- No database needed - all stored locally (with pros and cons coming with that approach)
- Support for multiple languages (currently English and Swedish)
- Fully customizable (as long as you know your PS)
- Add notes and a potential contact to each link
- Uses "System.Net.HttpListener"
- Kerberos Authentication via IIS and Negotiate middleware
- HTTPS/SSL support
- Dynamic Route Reloading - Routes automatically reload when file changes (hash-based detection)
- Request Signature Validation - HMAC-SHA256 signature verification in PowerShell backend
- Request Logging - All requests logged with user identity
- Security Headers - CSP, CORS, cache control, and more
- Secure User Authentication - HMAC-SHA256 signed headers
- CSRF Protection - Token-based protection for state-changing requests
- Gzip Compression - For API responses and assets
- Reverse Proxy - Forwards requests to PowerShell endpoints
- Configuration - All settings in appsettings.json (mirrors $ScriptVariables)
- Dynamic HTML Variables - PSVar_* configuration properties replaced in HTML templates
- Rate Limiting - Configurable per-user/IP request throttling
IIS (Port 443, HTTPS, Kerberos Auth)
↓
ASP.NET Core (Kestrel, Middleware)
↓
PowerShell Endpoints (localhost:8080+, HTTP)
- Public-facing .NET Wrapper (RestPSWrapper) - Handles authentication, authorization, and security controls
- Internal PowerShell Backend (Linx/RestPSModule) - Processes requests in a protected environment
RestPSWrapper/
├── Configuration/
│ ├── Route.cs # Route definition model
│ └── ScriptVariablesConfig.cs # Configuration binding (mirrors appsettings.json)
├── Controllers/
│ ├── CsrfController.cs # CSRF token management endpoints
│ └── ProxyController.cs # Main reverse proxy with HTML templating
├── Middleware/
│ ├── CompressionMiddleware.cs # Gzip compression
│ ├── GlobalExceptionHandlerMiddleware.cs
│ ├── RateLimitingMiddleware.cs
│ ├── RequestIdMiddleware.cs
│ ├── RequestLoggingMiddleware.cs
│ ├── RequestSizeLimitMiddleware.cs
│ └── SecurityMiddleware.cs # Unified security (Origin, CSRF, CSP, headers)
├── Services/
│ ├── ICsrfTokenService.cs # CSRF token generation & validation
│ ├── IPowerShellProxyService.cs # HTTP forwarding to PowerShell
│ ├── IRouteService.cs # Route loading & caching
│ ├── ISecurityHeaderService.cs # Security header management
│ ├── IUserContextService.cs # User identity & AD queries
│ └── SignatureService.cs # HMAC request signing
├── Filters/
│ └── AuditLoggingFilter.cs # Action-level audit logging
├── appsettings.json # Main configuration
├── Program.cs # Application bootstrap & DI
└── RestPSWrapper.csproj # .NET 10 project file
Linx/
├── endpoints/ # PowerShell REST endpoints
│ ├── Get-Links.ps1 # Main listing endpoint
│ ├── Get-LinksAdmin.ps1 # Admin interface
│ ├── Get-LinksPersonal.ps1 # Personal links
│ ├── Management.ps1 # Link CRUD operations
│ └── Routes.json # Route configuration
├── modules/
│ └── Internal-CmdLets.psm1 # Linx-specific helper functions
├── Start-Service.ps1 # Backend startup script
└── base_settings.json # Backend configuration
RestPSModule/
└── RestPSCustomModule.psm1 # Shared RestPS listener module
-
Run the script "bin\Set-Service.ps" in an elevated Powershell prompt (assign different parameters if NSSM or Linx paths are changed)
-
Open "services.msc" elevated and find the service "Linx"
-
Right-click and select "Properties" \ "Log on" \ "This account"
-
Enter the gMSA service account, clear the password fields and save by pressing "OK"
-
-
Open an elevated command prmopt and run these two commands (this is included in RestPS originally, but don't want the requirement of being a local admin to run it):
-
Replace FQDN, Port and Thumbprint used to match your environment:
netsh http add sslcert hostnameport=linx.domain.local:443 appid={2a81d04e-f297-46a6-b17a-3580fa3d91a5} certhash=THUMBPRINT certstorename=My -
Replace FQDN, Port, Domain and the gMSA service account used to match your environment
netsh http add urlacl url="https://linx.domain.local:443/" user="DOMAIN\gMSA-Linx$"
-
-
Configure "base_settings.json" to match your environment:
-
ServerURL: https://linx.domain.local (post https:// must match CN or SAN in certificate)
-
ShortURL: linx.domain.local (FQDN)
-
Domain: DOMAIN (short name of the domain)
-
SSLThumbprint: Check your certificates thumbprint and paste it here
-
AdminGroup: Common name of the group containing Linx-administrators
-
EditGroup: Common name of the group containing Linx-editors (a.k.a. admins without sugar)
-
OU_Admin: Distinguished name for the OU which holds your administrative accounts, if those shall have access to Linx
-
OU_Group: Distinguished name for the OU containing the Admin and Edit Linx groups
-
OU_User: Distinguished namr for the OU containing the standard users of Linx
Port and Language could also be specified if necessary...
-
-
Set file and folder permissions for gMSA service account
-
gMSA minimum permissions (recommended):
-
"Delete" files under "bin\Personal"
-
"Modify" on the following subfolders and files:
-
bin (folder)
-
images (folder)
-
lang (folder)
-
logs (folder)
-
settings (folder)
-
style (folder)
-
base_settings.json
-
-
-
gMSA sloppy ACLs (not recommended):
- "Full Control" on Linx subfolders and files
-
-
Start the service and go to Linx from another machine to start using it
-
Every now and then your certificate will expire, then:
-
Order a new certificate according to your local routines
-
Open "certlm.msc" and add the certificate to "Personal" \ "Certificates"
-
Remove the old certificate in the same store (recommended)
-
Open an elevated command prompt and run (changes values to match your environment):
netsh http delete sslcert hostnameport=linx.domain.local:443netsh http add sslcert hostnameport=linx.domain.local:443 appid={2a81d04e-f297-46a6-b17a-3580fa3d91a5} certhash=THUMBPRINT certstorename=My -
Open "base_settings.json' in an elevated editor and change "SSLThumbprint" to the one matching the new certificate
-
Restart the service
-
-
The service won't start:
- Verify that the minimum ACLs are set accordingaly
-
The service still won't start:
- Run "Start-Service.ps1" in a Powershell prompt started as the gMSA service account for further analysis
When you start the site for the first time you will have access to this menu if you login as Admin. The tab "ADMIN" will show if you have Edit permissions as well, but will be hidden for regular users. Selection of themes (to the right) is also an available setting to turn off if you desire for all users.
The ScriptVariables section contains all configuration that was previously in PowerShell:
"ScriptVariables": {
"ListenerUrl": "https://localhost:443",
"ScriptPath": "${RESTPS_HOME:C:/RestPS}",
"RoutesFilePath": "${RESTPS_HOME:C:/RestPS}/endpoints/Routes.json",
"LogDirectory": "${RESTPS_HOME:C:/RestPS}/logs",
"LogLevel": "INFO",
"HTMLCacheControl": "no-cache, no-store, must-revalidate",
"HTMLContentSecurityPolicy": "block-all-mixed-content; base-uri 'none'; object-src 'none';",
"HTMLXContentTypeOptions": "nosniff",
"ContentLanguage": "sv-SE",
"AccessControlAllowMethods": "",
"PowerShellServiceUrl": "http://localhost:8080",
"RequestSignatureSecret": "${RESTPS_SIGNATURE_SECRET}",
"IncludeUserEmail": false,
"IncludeUserDisplayName": false,
"IncludeUserGroups": false,
"IncludeUserSID": false,
"EnforceCorsOriginValidation": true,
"TrustedOrigins": "",
"RequireCsrfToken": true,
"CsrfTokenHeaderName": "X-CSRF-Token",
"CsrfTokenExpirationSeconds": 3600,
"CsrfBypassPaths": "/api/csrf/token,/api/csrf/health,/health",
"MaxRequestBodySizeBytes": 10485760,
"RateLimitRequestsPerMinute": 300,
"RateLimitBypassForLocalhost": true,
"HstsMaxAgeDays": 365,
"PSVar_PageTitle": "Application",
"PSVar_PageDescription": "",
"PSVar_HTMLLanguage": "sv"
}When forwarding requests to PowerShell endpoints, the wrapper adds secure headers that cannot be spoofed by clients:
- X-Authenticated-User - User identity from Kerberos (e.g.,
DOMAIN\username) - X-Request-Signature - HMAC-SHA256 signature of
username|method|path|body
Enable these in appsettings.json if needed:
- X-User-Email - User's email address (requires AD query)
- Set
IncludeUserEmail: true
- Set
- X-User-Display-Name - User's display name (requires AD query)
- Set
IncludeUserDisplayName: true
- Set
- X-User-Groups - User's AD groups, comma-separated (requires AD query)
- Set
IncludeUserGroups: true
- Set
- X-User-SID - User's Security Identifier
- Set
IncludeUserSID: true
- Set
# In your PowerShell endpoint script
$authenticatedUser = $Request.Headers['X-Authenticated-User']
$signature = $Request.Headers['X-Request-Signature']
Write-Host "Request from user: $authenticatedUser"
Write-Host "Request signature: $signature"
# Optional: Get additional user info if configured
if ($Request.Headers.ContainsKey('X-User-Email')) {
$email = $Request.Headers['X-User-Email']
Write-Host "User email: $email"
}The signature is computed using a secret key (RequestSignatureSecret) that should be:
- Changed from the default in production
- Kept secure - store in environment variables or secret manager
- Same on both sides - the PowerShell endpoints can validate signatures if needed
The wrapper includes built-in CSRF (Cross-Site Request Forgery) protection for all state-changing requests (POST, PUT, PATCH, DELETE).
- Session Cookie: When a user first accesses the API, a secure
SessionIdcookie is automatically created - Token Generation: The server generates a unique CSRF token tied to the session
- Token Delivery: The token is sent to the client in the
X-CSRF-Tokenresponse header - Token Validation: State-changing requests must include the token in the
X-CSRF-Tokenrequest header - Automatic Refresh: Each response includes a new token for the next request
"ScriptVariables": {
"RequireCsrfToken": true,
"CsrfTokenHeaderName": "X-CSRF-Token",
"CsrfTokenExpirationSeconds": 3600,
"CsrfBypassPaths": "/api/csrf/token,/api/csrf/health,/health"
}Endpoint: GET /api/csrf/token
This endpoint is always accessible (bypasses CSRF validation) and returns:
{
"token": "base64-encoded-token-here",
"headerName": "X-CSRF-Token",
"expiresIn": "3600 seconds",
"usage": "Include this token in the X-CSRF-Token header for POST/PUT/PATCH/DELETE requests"
}# Step 1: Get CSRF token
$response = Invoke-RestMethod `
-Uri "https://localhost:443/api/csrf/token" `
-Method GET `
-UseDefaultCredentials `
-SessionVariable session
$csrfToken = $response.token
# Step 2: Use token in POST request
$body = @{ data = "value" } | ConvertTo-Json
Invoke-RestMethod `
-Uri "https://localhost:443/api/your-endpoint" `
-Method POST `
-Headers @{ "X-CSRF-Token" = $csrfToken } `
-Body $body `
-ContentType "application/json" `
-UseDefaultCredentials `
-WebSession $session// Step 1: Get CSRF token
let csrfToken = null;
async function getCsrfToken() {
const response = await fetch('/api/csrf/token', {
method: 'GET',
credentials: 'include' // Important for cookies
});
const data = await response.json();
csrfToken = data.token;
}
// Step 2: Use token in POST request
async function postData(data) {
await getCsrfToken(); // Get token if not already available
const response = await fetch('/api/your-endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'include',
body: JSON.stringify(data)
});
// Update token from response for next request
csrfToken = response.headers.get('X-CSRF-Token');
return response.json();
}- GET requests: Do NOT require CSRF tokens
- POST/PUT/PATCH/DELETE requests: MUST include a valid CSRF token
- Cookies: Must be enabled (SessionId cookie is required)
- HTTPS: Required in production (cookies are marked Secure)
- Token Refresh: Each response includes a new token in the
X-CSRF-Tokenheader - Token Expiration: Tokens expire after 1 hour (configurable)
GET /api/csrf/token- Get a new CSRF tokenPOST /api/csrf/invalidate- Invalidate current session tokens (logout)GET /api/csrf/health- Health check for CSRF service
For development/testing, you can temporarily disable CSRF protection:
"RequireCsrfToken": falseWarning: Never disable CSRF in production environments!
TRACE- Verbose diagnostic loggingDEBUG- Debug informationINFO- General information (default)WARN- Warning messagesERROR- Error messagesFATAL- Fatal errors
dotnet build RestPSWrapper.csprojdotnet publish -c Release -r win-x64 --self-containeddotnet publish -c Release -o C:\inetpub\wwwroot\RestPS- Create an IIS application pointing to the published folder
- Enable Windows Authentication (Negotiate)
- Configure HTTPS binding with your certificate
- Set application pool identity to run as application user
- Ensure PowerShell endpoints are accessible on localhost:8080
- Update
appsettings.jsonwith:- Correct
ListenerUrl - Path to
RoutesFilePath RequestSignatureSecret(use a strong random value)
- Correct
dotnet run --configuration Developmentdotnet run --configuration ProductionThis project implements a defense-in-depth security architecture with two layers:
- Public-facing .NET Wrapper (RestPSWrapper) - Handles authentication, authorization, and security controls
- Internal PowerShell Backend (Linx/RestPSModule) - Processes requests in a protected environment
The public-facing layer provides:
- Authentication: Kerberos/Negotiate (Windows Integrated Authentication)
- Authorization: Role-based access control
- CSRF Protection: Token-based validation for state-changing requests
- Rate Limiting: Per-IP request throttling
- Security Headers: CSP, X-Frame-Options, HSTS, etc.
- Input Validation: Request size limits, content type validation
- Request Signing: HMAC-SHA256 signatures sent to backend
Endpoint: Configured via ListenerUrl (default: http://+:8088)
The internal processing layer:
- Network Isolation: Binds to
localhostonly (not exposed externally) - Anonymous Authentication: By design - network isolation is the security boundary
- Request Processing: Executes PowerShell scripts based on route configuration
Endpoint: http://localhost:8080 (localhost-only by design)
The PowerShell backend uses Anonymous authentication intentionally because:
- Network Isolation: Backend binds to
localhost:8080only - not accessible from external networks - Single Point of Entry: All external requests MUST go through the .NET wrapper
- Simplified Architecture: Authentication/authorization logic in one place (wrapper)
- Defense in Depth: Wrapper provides multiple security layers before reaching backend
The wrapper generates HMAC-SHA256 signatures for all forwarded requests:
- Header:
X-Request-Signature - Signature Data:
{user}|{method}|{path}|{bodyLength}|{body} - Purpose: Backend validates signatures to detect tampering and ensure requests originated from the authorized wrapper
Current Status: ✅ IMPLEMENTED - Signatures are generated by the wrapper and validated by the backend.
Implementation: Backend validation occurs in RestPSCustomModule.psm1 (lines 384-387), automatically rejecting requests with invalid or missing signatures when RequestSignatureSecret is configured.
Ensure base_settings.json contains:
{
"ShortURL": "localhost"
}WARNING: Never change this to "0.0.0.0" or "+" as it would expose the unauthenticated backend externally.
The wrapper must connect to localhost backend:
{
"PowerShellServiceUrl": "http://localhost:8080"
}The AllowedHosts setting in appsettings.json controls Host header validation to prevent Host header injection attacks and cache poisoning.
Development (Current Default):
{
"AllowedHosts": "*"
}* disables validation - acceptable for development but should be restricted in production.
Production (Recommended):
{
"AllowedHosts": "linx.example.com;www.linx.example.com"
}Format Rules:
- Semicolon-separated list (
;not,) - Hostname only (no
https://protocol) - Include port if non-standard:
example.com:8443 - Subdomain wildcards supported:
*.example.com - Multiple domains allowed:
site1.com;site2.net
Examples:
// Single production domain
"AllowedHosts": "linx.company.com"
// Multiple domains
"AllowedHosts": "linx.company.com;www.linx.company.com;api.linx.company.com"
// Subdomain wildcard
"AllowedHosts": "*.company.com"
// Multiple domains with ports
"AllowedHosts": "linx.company.com:443;backup.company.com:8443"
// Development/localhost
"AllowedHosts": "localhost;127.0.0.1;*"Security Impact:
- ✅ Configured: Prevents Host header injection, cache poisoning, and routing attacks
⚠️ Wildcard (*): Disables protection - only use in isolated development environments
Recommendation: Set to actual production domain(s) before deployment. Update during deployment automation or use environment variables.
✅ External unauthorized access (Kerberos authentication)
✅ CSRF attacks (token validation)
✅ Brute force (rate limiting)
✅ Clickjacking (X-Frame-Options)
✅ XSS (Content-Security-Policy)
✅ Large payload attacks (request size limits)
✅ Replay attacks (signature includes timestamp/nonce via CSRF)
✅ Request tampering (HMAC-SHA256 signature validation)
🔜 Backend rate limiting (secondary layer)
🔜 Enhanced audit logging with correlation IDs
- Firewall Rules: Only expose wrapper port (8088), block backend port (8080)
- Process Isolation: Run backend under least-privilege service account
- Monitoring: Log all authentication failures and rate limit violations
- Certificate Management: Use valid SSL certificates for production (HSTS enabled)
- Configuration Review: Verify
ShortURLis always"localhost"
For security issues or questions about this architecture:
- Review code comments in
RestPSCustomModule.psm1(lines 212-227) - Review code comments in
ProxyController.cs(lines 75-90) - Consult configuration files:
appsettings.json,base_settings.json
- 2024: Initial security architecture with wrapper layer
- Current: Request signing and validation fully implemented
- Dynamic route reloading with hash-based change detection
- HMAC-SHA256 signature validation in PowerShell backend
The RestPSWrapper .NET application signs all requests forwarded to the PowerShell backend using HMAC-SHA256. The PowerShell backend validates these signatures to ensure requests originated from the authorized wrapper and haven't been tampered with.
Status: ✅ ACTIVE - Global signature validation is now enforced in RestPSCustomModule.psm1 (lines 384-387).
The .NET wrapper (ProxyController.cs) generates a signature for every request:
// Signature includes: user | method | path | body length | body content
var bodyForSignature = string.IsNullOrEmpty(body) ? "[EMPTY]" : body;
var bodyLength = body?.Length ?? 0;
var signatureData = $"{userName}|{Request.Method}|{path}|{bodyLength}|{bodyForSignature}";
var signature = _signatureService.GenerateSignature(signatureData); // HMAC-SHA256
userHeaders["X-Request-Signature"] = signature;The signature is sent in the X-Request-Signature HTTP header.
Current Implementation: The PowerShell backend now automatically validates all request signatures in RestPSCustomModule.psm1:
# Lines 384-387 in RestPSCustomModule.psm1
if ( $ScriptVariables.RequestSignatureSecret ) {
if ( 'null' -ne $Body ) { $SignatureBody = $Body } else { $SignatureBody = '' }
if ( (Test-RequestSignature -Request $Request -Body $SignatureBody -Secret $ScriptVariables.RequestSignatureSecret) -eq $false ) { return $false }
}When RequestSignatureSecret is configured, the backend automatically:
- Validates the signature for every incoming request
- Rejects requests with invalid or missing signatures (returns
$false) - Only processes requests with valid signatures
Optional Per-Endpoint Validation: You can also validate signatures in individual endpoint scripts:
# In your endpoint script or in the RestPSCustomModule request handler
$isValid = Test-RequestSignature -Request $script:Request -Body $script:Body -Secret $ScriptVariables.RequestSignatureSecret
if (-not $isValid) {
$script:StatusCode = 401
$script:StatusDescription = "Unauthorized - Invalid Signature"
return $null
}
# Continue processing the request...Both applications must share the same secret:
{
"ScriptVariables": {
"RequestSignatureSecret": "${RESTPS_SIGNATURE_SECRET}"
}
}Use an environment variable for production:
$env:RESTPS_SIGNATURE_SECRET = "your-strong-secret-key-here-min-32-chars"{
"RequestSignatureSecret": "your-strong-secret-key-here-min-32-chars"
}- At least 32 characters long (longer is better)
- Cryptographically random (use
[System.Guid]::NewGuid().ToString()or similar) - Identical in both applications
- Stored securely (use environment variables or encrypted config in production)
# Generate a strong secret (64 characters - recommended)
$secret = -join ((65..90) + (97..122) + (48..57) | Get-Random -Count 64 | ForEach-Object {[char]$_})
Write-Host "Generated Secret: $secret"
# Or use GUIDs
$secret = ([guid]::NewGuid().ToString() -replace '-','') + ([guid]::NewGuid().ToString() -replace '-','')
Write-Host "Generated Secret (GUID-based): $secret"Current Implementation: Signature validation is now active in RestPSCustomModule.psm1 (lines 384-387):
if ( $ScriptVariables.RequestSignatureSecret ) {
if ( 'null' -ne $Body ) { $SignatureBody = $Body } else { $SignatureBody = '' }
if ( (Test-RequestSignature -Request $Request -Body $SignatureBody -Secret $ScriptVariables.RequestSignatureSecret) -eq $false ) { return $false }
}When RequestSignatureSecret is configured:
- ✅ All incoming requests are automatically validated
- ✅ Requests with invalid signatures are rejected before routing
- ✅ No additional endpoint-level validation required
- ✅ Provides defense-in-depth security by default
If you need additional logging or custom behavior for specific endpoints, you can add endpoint-level validation:
# In Get-LinksAdmin.ps1 or other sensitive endpoints
param($RequestArgs, $Body)
# Additional validation with custom logging (global validation already occurred)
$isValid = Test-RequestSignature -Request $script:Request -Body $Body -Secret $ScriptVariables.RequestSignatureSecret
if (-not $isValid) {
return @{
StatusCode = 401
Body = '{"error":"Unauthorized - Invalid Signature"}'
ContentType = 'application/json'
}
}
# Continue with normal endpoint logic...Note: Since global validation is now active, per-endpoint validation is only needed for:
- Custom error messages or logging
- Additional audit requirements
- Development/testing purposes
✅ Phase 3 - Full Enforcement (CURRENT)
- Global validation active in RestPSCustomModule (lines 384-387)
- All requests validated automatically when
RequestSignatureSecretis configured - Invalid signatures rejected before routing occurs
- Defense-in-depth protection fully implemented
- Request Tampering - Body or headers modified in transit
- Replay Attacks - Signature includes request-specific data (method, path, body)
- Unauthorized Direct Access - Only wrapper-signed requests are accepted
- Man-in-the-Middle - Any modification invalidates the signature
- Localhost Attacks - Backend still binds to localhost only; signature doesn't help if attacker has localhost access
- Timing Attacks - Function uses constant-time comparison to mitigate, but PowerShell performance varies
- Secret Compromise - If secret is leaked, attacker can forge signatures
- Use Environment Variables for secrets in production
- Rotate Secrets Periodically (requires coordinated update in both apps)
- Monitor Invalid Signatures - High rate may indicate attack
- Keep Backend Localhost-Only - Network isolation is primary defense
- Use HTTPS between wrapper and backend if not on same machine (not recommended)
- Log Signature Failures - Include method, path, and timestamp
# Management.ps1 - Secure endpoint with signature validation
param($RequestArgs, $Body)
# Step 1: Validate signature
if (-not (Test-RequestSignature -Request $script:Request -Body $Body -Secret $ScriptVariables.RequestSignatureSecret)) {
Write-Log -LogFile $Logfile -LogLevel ERROR -MsgType ERROR -Message "Signature validation failed for Management endpoint"
return @{
StatusCode = 401
StatusDescription = "Unauthorized"
Body = '{"error":"Invalid request signature"}'
ContentType = 'application/json'
} | ConvertTo-Json
}
# Step 2: Get authenticated user from wrapper
$authenticatedUser = $script:Request.Headers["X-Authenticated-User"]
if ([string]::IsNullOrEmpty($authenticatedUser)) {
return @{
StatusCode = 401
Body = '{"error":"No authenticated user"}'
ContentType = 'application/json'
} | ConvertTo-Json
}
# Step 3: Parse and process request
try {
$data = $Body | ConvertFrom-Json
# Your business logic here...
$result = Process-ManagementRequest -Data $data -User $authenticatedUser
return @{
StatusCode = 200
Body = $result | ConvertTo-Json
ContentType = 'application/json'
} | ConvertTo-Json
}
catch {
Write-Log -LogFile $Logfile -LogLevel ERROR -MsgType ERROR -Message "Error processing management request: $_"
return @{
StatusCode = 500
Body = '{"error":"Internal server error"}'
ContentType = 'application/json'
} | ConvertTo-Json
}# Test signature generation and validation
$secret = "test-secret-key-at-least-32-characters-long"
# Simulate request data
$user = "testuser"
$method = "POST"
$path = "/api/test"
$body = '{"test":"data"}'
$bodyLength = $body.Length
# Generate signature (mimics wrapper)
$signatureData = "$user|$method|$path|$bodyLength|$body"
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($secret)
$hashBytes = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signatureData))
$signature = [Convert]::ToBase64String($hashBytes)
$hmac.Dispose()
Write-Host "Signature Data: $signatureData"
Write-Host "Generated Signature: $signature"
# Now validate it (create a mock request object for testing)
# In production, you'd use the actual $script:Request from the listener1. Signature Always Invalid
- Check that both apps use the exact same secret (case-sensitive)
- Verify body encoding - wrapper uses UTF-8
- Check for trailing whitespace in secret strings
2. Empty Body Signatures Fail
- Ensure empty bodies use
"[EMPTY]"string in both wrapper and backend - Check bodyLength calculation (0 for empty)
3. Performance Impact
- Signature validation adds ~1-5ms per request
- Consider validating only state-changing requests (POST/PUT/DELETE)
Add to your endpoint:
Write-Host "=== Signature Debug ==="
Write-Host "User: $($script:Request.Headers['X-Authenticated-User'])"
Write-Host "Method: $($script:Request.HttpMethod)"
Write-Host "Path: $($script:Request.Url.AbsolutePath)"
Write-Host "Body Length: $($Body.Length)"
Write-Host "Body: $Body"
Write-Host "Provided Signature: $($script:Request.Headers['X-Request-Signature'])"
Write-Host "======================="✅ COMPLETED - All3 phases have been implemented:
-
✅ Phase 1 - Deploy (Completed)
- ✅
Test-RequestSignaturefunction added - ✅ Logging implemented
- ✅ Monitoring verified
- ✅
-
✅ Phase 2 - Selective (Completed)
- ✅ Tested on admin/sensitive endpoints
- ✅ Validation logic verified
-
✅ Phase 3 - Full Enforcement (CURRENT - ACTIVE)
- ✅ Global validation enabled in RestPSCustomModule (lines 384-387)
- ✅ All requests validated automatically
- ✅ Invalid signatures rejected before routing
- Wrapper signature generation:
RestPSWrapper/Services/SignatureService.cs - Wrapper signature usage:
RestPSWrapper/Controllers/ProxyController.cs(line 86-88) - Backend validation function:
RestPSModule/RestPSCustomModule.psm1(Test-RequestSignature) - Backend validation enforcement:
RestPSModule/RestPSCustomModule.psm1(lines 384-387) - Security architecture:
SECURITY.md
The RestPSWrapper now dynamically protects security headers configured in appsettings.json from being overwritten by the PowerShell backend.
Headers are automatically protected based on configuration - no hardcoded list needed:
- Any security header set from
appsettings.jsonduring theSecurityMiddlewarepipeline is tracked - These headers are stored in
HttpContext.Items["ProtectedHeaders"] - The
ProxyControllerchecks this list before applying PowerShell backend headers
The following headers are dynamically protected when configured:
Cache-Control- fromHTMLCacheControlPragma- always set to "no-cache"Expires- always set to "0"Content-Security-Policy- fromHTMLContentSecurityPolicyX-Content-Type-Options- fromHTMLXContentTypeOptionsContent-Language- fromContentLanguageX-Frame-Options- always set to "DENY"X-Permitted-Cross-Domain-Policies- always set to "none"Strict-Transport-Security- calculated fromHstsMaxAgeDays(HTTPS only)
When the PowerShell backend attempts to overwrite a protected header:
[Warning] PowerShell backend attempted to overwrite protected header 'Cache-Control'.
This header is set in appsettings.json and cannot be overwritten.
Backend value 'public, max-age=3600' was ignored.
✅ Dynamic - Adapts automatically to configuration changes
✅ Secure - appsettings.json remains the source of truth for security headers
✅ Transparent - Logs warnings when PS backend tries to overwrite protected headers
✅ Flexible - Non-security headers from PS backend are still forwarded normally
{
"ScriptVariables": {
"HTMLCacheControl": "no-cache, no-store, must-revalidate",
"HTMLContentSecurityPolicy": "default-src 'self'",
"ContentLanguage": "sv-SE"
}
}# These headers will be IGNORED (protected):
$response.Headers["Cache-Control"] = "public, max-age=3600"
$response.Headers["Content-Security-Policy"] = "default-src *"
# These headers will be FORWARDED (not protected):
$response.Headers["X-Custom-Header"] = "custom-value"
$response.Headers["X-Request-Id"] = "12345"Cache-Control: "no-cache, no-store, must-revalidate" (from appsettings.json ✅)Content-Security-Policy: "default-src 'self'" (from appsettings.json ✅)Content-Language: "sv-SE" (from appsettings.json ✅)X-Custom-Header: "custom-value" (from PS backend ✅)X-Request-Id: "12345" (from PS backend ✅)
Services/ISecurityHeaderService.cs- Updated interface to return protected headersServices/SecurityHeaderService.cs- Tracks and returns protected header namesMiddleware/SecurityMiddleware.cs- Stores protected headers in HttpContextControllers/ProxyController.cs- Checks protected headers before applying backend headers
The PowerShell backend (RestPSModule/RestPSCustomModule.psm1) provides additional security and performance features:
Routes are loaded into memory and automatically reloaded when the Routes.json file changes:
- Initial Load: Routes loaded on first request (lines 109-122)
- Hash-Based Detection: File hash checked on each request (line 123)
- Automatic Reload: Routes reloaded when hash changes (lines 124-136)
- Error Handling: Reload failures logged; previous routes remain active
- Performance: Hash comparison is fast; full reload only on actual changes
Benefits:
- Zero downtime route updates
- No service restart required
- Automatic rollback on reload failure
- Transparent to clients
All requests from the wrapper are validated using HMAC-SHA256 signatures:
- Implementation: Lines 384-387 in
RestPSCustomModule.psm1 - Enforcement: Enabled when
RequestSignatureSecretis configured - Behavior: Invalid or missing signatures rejected before routing
- Defense-in-Depth: Prevents tampering and unauthorized direct access
See Linx/SIGNATURE-VALIDATION.md for detailed documentation.
Loads and caches routes from JSON configuration file. Routes are automatically reloaded when the file changes in the PowerShell backend (hash-based detection).
Forwards HTTP requests to PowerShell endpoints running on localhost, including secure user authentication headers.
Applies security headers to responses (CSP, CORS, cache control, etc.).
Extracts authenticated user information from HttpContext and optionally queries Active Directory for additional user details.
Generates and verifies HMAC-SHA256 signatures for secure request authentication.
Manages CSRF token generation, validation, session tracking, and expiration with in-memory storage and automatic cleanup.
Logs all incoming requests with user identity and response status.
Unified security middleware that handles:
- Origin validation against trusted domains
- CSP nonce generation for inline scripts
- Session cookie management
- CSRF token validation for state-changing requests
- CSRF token generation for response headers
- Security header application (CSP, CORS, cache control, etc.)
Applies gzip compression to API responses and assets.
Configurable rate limiting per user/IP to prevent abuse.
Generates unique request IDs for tracing and correlation.
Centralized exception handling and error response formatting.
Enforces maximum request body size limits.
RestPSWrapper automatically replaces template variables in HTML responses. Any configuration property starting with PSVar_ becomes a dynamic variable that can be used in HTML.
Configuration (appsettings.json):
{
"ScriptVariables": {
"PSVar_PageTitle": "My App",
"PSVar_PageLanguage": "en",
"PSVar_CompanyName": "Acme Corporation",
"PSVar_SupportEmail": "support@acme.com",
"PSVar_Version": "1.0.0"
}
}HTML:
<html lang="{{PSVar_PageLanguage}}">
<head>
<title>{{PSVar_PageTitle}}</title>
</head>
<body>
<h1>Welcome to {{PSVar_CompanyName}}</h1>
<p>Version: {{PSVar_Version}}</p>
<footer>Contact: {{PSVar_SupportEmail}}</footer>
</body>
</html>{{nonce}}- CSP nonce (generated per request for inline scripts/styles)
- Add
PSVar_*properties toappsettings.json - Use
{{PSVar_PropertyName}}in HTML/PowerShell templates - Restart application to load changes
Note: Placeholder replacement is case-insensitive:
- Config:
PSVar_CompanyName→ Template:{{PSVar_CompanyName}},{{psvar_companyname}}, or{{PSVAR_COMPANYNAME}}all work - Config:
psVar_Test→ Template:{{PSVar_Test}},{{psvar_test}}, or any case variant works - The prefix check in config is also case-insensitive (
psvar_,PSVAR_,PSVar_all accepted)
Use Cases:
- Page metadata (title, description, language)
- Branding (company name, logo URLs)
- Contact information
- API endpoints and URLs
- Version numbers and build info
- Environment-specific settings
- Feature flags
appsettings.json- Default settingsappsettings.Development.json- Development overrides (debug logging)appsettings.Production.json- Production overrides (URLs, log level)
Detailed security reviews are available in the Security-review/ folder:
Linx-2.0-security-review.md- Application-layer security reviewRestPSWrapper-security-review.md- Transport-layer security review
Project Repository: https://github.com/froxxon/Linx-2.0
