software 2025 · several months of fine-tuning · 0 €

HTTP Firewall for IIS in VB.NET: block XMLRPC, brute force and attack patterns before they reach WordPress

🌐 Leer esta página en español →

A WordPress site on a Windows server with IIS exposed to the internet takes only a few hours to get its first requests to xmlrpc.php, its first serial login attempts and its first vulnerability scans. Most solutions you'll find online are built for Linux. This tutorial explains how to build an HTTP module for IIS in VB.NET that intercepts every request before it reaches the application, with two layers of blocking: application and Windows Firewall.

01 — The problem and the idea

The scenario is common in corporate environments: a Windows server running IIS, several WordPress sites or PHP applications, no budget for a commercial WAF and no technical justification to migrate to Linux. The server runs fine, but the IIS logs keep growing with automated requests nobody is watching.

The three most common attack vectors against WordPress are the usual suspects. First, xmlrpc.php: it's been unnecessary in most installations for years but stays open, and bots use it for amplification and mass authentication attempts without ever showing up in the standard login logs. Second, brute force against wp-login.php: scripts that try thousands of username and password combinations. Third, reconnaissance scanners: automated tools that probe hundreds of known paths looking for exposed configuration files, webshells, admin paths, SQL injection points.

The solution proposed here acts before the request ever reaches PHP. It's not a WordPress plugin, it's not an external reverse proxy. It's code that lives inside IIS's own pipeline.

02 — What an IIS HTTP module is

IIS processes every HTTP request by passing it through a chain of modules. Each module can inspect the request, modify it, respond directly, or simply let it pass. Some are native (authentication, compression, logging), and you can add your own.

A custom HTTP module is a .NET DLL that implements the IHttpModule interface. This interface defines two methods: Init(), called once when IIS loads the module, and Dispose(), called when it's unloaded. Init() registers a handler for the event we care about. In our case, BeginRequest: the first event in the pipeline, fired before IIS even determines which application will handle the request.

That means if the module decides to return a 403 Forbidden, the request dies right there. WordPress never finds out, PHP never starts, there's no database access, no load at all. The DLL is registered in Windows's GAC (Global Assembly Cache) and declared in IIS's applicationHost.config, so it automatically protects every site on the server.

03 — Architecture: three layers

Request Incoming HTTP IIS + HTTP module IHttpModule / BeginRequest (DLL in GAC) OK → Application WordPress / PHP attack SQL Server SGPFirewall 403 Forbidden hackers.txt IPs to block PowerShell scheduled task Windows Firewall network-level blocking (async) ──────────────────────────
The three layers: the HTTP module intercepts in real time, SQL Server stores the state, and a PowerShell script turns the most dangerous IPs into Windows Firewall rules.

04 — The module: Init and BeginRequest

The Visual Studio project is a Class Library (.NET Framework 4.8), signed with a strong-name certificate (required for the GAC). The references you need to add are System.Web and System.Configuration. The main file is a class that implements IHttpModule.

The module's basic structure:

Option Explicit On
Option Strict On

Imports System.Web
Imports System.Configuration
Imports System.Data
Imports System.Data.SqlClient
Imports System.IO

Public Class manejadorHttp
    Implements IHttpModule

    Private Const RUTA_FICHERO_HACKERS As String = "C:\SGPFirewall\hackers.txt"
    Private Shared ReadOnly LockFichero As New Object()

    Public Sub Init(ByVal application As HttpApplication) _
        Implements IHttpModule.Init
        AddHandler application.BeginRequest, AddressOf Me.Application_BeginRequest
    End Sub

    Public Sub Dispose() Implements IHttpModule.Dispose
    End Sub

End Class

Init() receives the IIS application instance and registers our method as the handler for the BeginRequest event. From there on, Application_BeginRequest runs for every request that reaches the server, on its corresponding thread. LockFichero is a synchronization object for when several threads try to write to hackers.txt at the same time.

The main method, with the full logic:

Private Sub Application_BeginRequest(ByVal source As Object, ByVal e As EventArgs)
    Dim application As HttpApplication = DirectCast(source, HttpApplication)
    Dim context As HttpContext = application.Context
    Dim filePath As String = context.Request.FilePath.ToLower()

    ' 1. Static files: pass without analysis
    If EsArchivoEstatico(filePath) Then Exit Sub

    Dim ip_actual As String = nz(pilla_IP())

    ' 2. Whitelist: trusted IPs, free pass
    If EsIPAmiga(ip_actual) Then Exit Sub

    ' 3. IP already banned previously
    Dim infracciones As Integer = compruebaIP(ip_actual)
    If infracciones >= 3 Then
        BloquearYTerminar(application, "IP Banned Permanently")
        Exit Sub
    End If

    ' 4. RULE 1: XMLRPC — zero tolerance
    If filePath.Contains("xmlrpc.php") Then
        Logea(5, "XMLRPC Attack Detected")
        set_ips(ip_actual, 1, 100)
        GuardarIPParaFirewall(ip_actual)
        BloquearYTerminar(application, "XMLRPC Blocked")
        Exit Sub
    End If

    ' 5. RULE 2: Brute force
    If filePath.Contains("wp-login.php") OrElse filePath.Contains("login") Then
        Logea(1, "Login Attempt")
        Dim intentosRecientes As Integer = contarIntentosRecientes(ip_actual, 60)
        If intentosRecientes >= 5 Then
            Logea(9, "Brute Force Detected (>5 attempts/min)")
            set_ips(ip_actual, 1, 100)
            GuardarIPParaFirewall(ip_actual)
            BloquearYTerminar(application, "IP baneada por exceso de intentos de login.")
            Exit Sub
        End If
    End If

    ' 6. RULE 3: Attack patterns (SQLi, XSS, path traversal...)
    Dim offe As Integer = buscaOfensa(filePath)
    If offe <> 0 Then
        Logea(5, "Attack Detected: " & filePath)
        If infracciones = 0 Then
            set_ips(ip_actual, 0, 1)
            infracciones = 1
        Else
            incrementa_Infracciones(ip_actual)
            infracciones += 1
        End If
        If infracciones >= 1 Then
            set_ips(ip_actual, 1, infracciones)
            GuardarIPParaFirewall(ip_actual)
            BloquearYTerminar(application, "IP baneada")
            Exit Sub
        End If
    End If

    ' If it gets here: legitimate request, we log nothing
End Sub

05 — The decision flow and the three rules

HTTP request Static file? .jpg .css .js .png… Yes PASS No IP in whitelist? local network, admin… Yes PASS No RULE 1 — XMLRPC URL contains xmlrpc.php? → immediate 403 + DB(100) + firewall RULE 2 — Brute force wp-login.php / login? → count attempts / 60s If ≥ 5 → 403 + DB(100) + firewall RULE 3 — Patterns (SQLi / XSS / shells) URL in pattern list? → +1 violation in DB If ≥ 3 violations → 403 + firewall Request passes to the application
The complete decision flow for every request. The rules are applied in order; if any of them fires, the request dies right there.

A few design decisions worth understanding. Static files are filtered first because they make up most of the traffic on a normal site: there's no point querying SQL Server for every .jpg. The whitelist comes next: trusted IPs pass through with no logging or analysis at all. This matters because the server's own IP (localhost) needs to be in the whitelist, or the module would end up fighting itself.

Rule 1 (XMLRPC) is zero-tolerance because there's no legitimate way to reach xmlrpc.php on a standard WordPress install. A single attempt is enough for a permanent ban: state 1 (Blacklisted) and 100 violations in the database, plus the IP written to the file for Windows Firewall.

Rule 2 (brute force) uses a 60-second time window. It queries the log table and counts how many "Login Attempt" events that IP has generated in the last minute. Five or more, and it's blocked. This lets a legitimate user who mistypes their password go unpunished, while still catching scripts that fire off dozens of attempts per second.

Rule 3 (patterns) uses a strikes system. The first pattern detected earns one violation, the second another, the third gets you blocked. This cuts down on false positives: one odd URL by itself isn't necessarily an attack, but the same IP hitting three different odd URLs is, much more likely than not.

06 — The database helpers

Each helper function opens its own connection, uses it and closes it. VB.NET's Using guarantees the connection is closed even if an error occurs. Every function has a Try/Catch that returns a safe value on failure: false for booleans, zero for integers. The module can't afford to let a database error take down the web server.

' Connection string from the global .NET configuration
Private Shared Function Cadena_Aplicacion() As String
    If ConfigurationManager.ConnectionStrings("SGPFirewallConn") IsNot Nothing Then
        Return ConfigurationManager.ConnectionStrings("SGPFirewallConn").ConnectionString
    End If
    Return ""
End Function

' Extracts the client IP, respecting proxies
Private Function pilla_IP() As String
    Dim ip As String = HttpContext.Current.Request.ServerVariables("HTTP_X_FORWARDED_FOR")
    If String.IsNullOrEmpty(ip) Then ip = HttpContext.Current.Request.ServerVariables("REMOTE_ADDR")
    If ip.Contains(",") Then ip = ip.Split(","c)(0)
    Return ip.Trim()
End Function

' Null-safe: returns "0" if the object is Nothing or DBNull
Private Function nz(ByVal obj As Object) As String
    If obj Is Nothing OrElse IsDBNull(obj) Then Return "0"
    Return obj.ToString()
End Function

' Is the IP in the whitelist?
Private Function EsIPAmiga(ip As String) As Boolean
    Try
        Using conex As New SqlConnection(Cadena_Aplicacion())
            Using cmd As New SqlCommand("esWhitelisted", conex)
                cmd.CommandType = CommandType.StoredProcedure
                cmd.Parameters.Add("@IP", SqlDbType.NVarChar, 20).Value = ip
                conex.Open()
                Return CInt(cmd.ExecuteScalar()) > 0
            End Using
        End Using
    Catch
        Return False
    End Try
End Function

' How many violations does the IP have?
Private Function compruebaIP(ip As String) As Integer
    Try
        Using conex As New SqlConnection(Cadena_Aplicacion())
            Using cmd As New SqlCommand(
                "SELECT TOP 1 contador_infracciones FROM ips WHERE ip = @ip", conex)
                cmd.Parameters.AddWithValue("@ip", ip)
                conex.Open()
                Return CInt(nz(cmd.ExecuteScalar()))
            End Using
        End Using
    Catch
        Return 0
    End Try
End Function

' Counts login attempts from an IP in the last N seconds
Private Function contarIntentosRecientes(ip As String, segundos As Integer) As Integer
    Try
        Using conex As New SqlConnection(Cadena_Aplicacion())
            Using cmd As New SqlCommand("check_intentos_recientes", conex)
                cmd.CommandType = CommandType.StoredProcedure
                cmd.Parameters.Add("@IP", SqlDbType.NVarChar, 20).Value = ip
                cmd.Parameters.Add("@Segundos", SqlDbType.Int).Value = segundos
                conex.Open()
                Return CInt(nz(cmd.ExecuteScalar()))
            End Using
        End Using
    Catch
        Return 0
    End Try
End Function

' Logs a security event in the log table
Private Sub Logea(ByVal codigo_evento As Integer, ByVal descripcion As String)
    Try
        Using conex As New SqlConnection(Cadena_Aplicacion())
            Using cmd As New SqlCommand("logea", conex)
                cmd.CommandType = CommandType.StoredProcedure
                cmd.Parameters.Add("@codigo_evento", SqlDbType.Int).Value = codigo_evento
                cmd.Parameters.Add("@descripcion", SqlDbType.NVarChar, 255).Value =
                    If(descripcion.Length > 254, descripcion.Substring(0, 254), descripcion)
                cmd.Parameters.Add("@fecha_hora", SqlDbType.DateTime).Value = DateTime.Now
                cmd.Parameters.Add("@IP", SqlDbType.NVarChar, 20).Value = nz(pilla_IP())
                conex.Open()
                cmd.ExecuteNonQuery()
            End Using
        End Using
    Catch
    End Try
End Sub

' Inserts or updates an IP's state in the ips table
Private Sub set_ips(ByVal ip As String, estado As Byte,
                    Optional num_infracciones As Integer = 1)
    Try
        Using conex As New SqlConnection(Cadena_Aplicacion())
            Using cmd As New SqlCommand("set_ips", conex)
                cmd.CommandType = CommandType.StoredProcedure
                cmd.Parameters.Add("@estado", SqlDbType.SmallInt).Value = estado
                cmd.Parameters.Add("@ip", SqlDbType.NVarChar, 15).Value = nz(ip)
                cmd.Parameters.Add("@num_infracciones", SqlDbType.Int).Value = num_infracciones
                conex.Open()
                cmd.ExecuteNonQuery()
            End Using
        End Using
    Catch
    End Try
End Sub

' Increments an IP's violation counter
Private Sub incrementa_Infracciones(ByVal ip As String)
    Try
        Using conex As New SqlConnection(Cadena_Aplicacion())
            Using cmd As New SqlCommand("incrementaInfracciones", conex)
                cmd.Parameters.Add("@ip", SqlDbType.NVarChar, 15).Value = nz(ip)
                conex.Open()
                cmd.ExecuteNonQuery()
            End Using
        End Using
    Catch
    End Try
End Sub

' Checks the URL against the list of forbidden patterns in the DB
Private Function buscaOfensa(ByVal responseUrl As String) As Integer
    Try
        Using conex As New SqlConnection(Cadena_Aplicacion())
            Using cmd As New SqlCommand("comparaOfending", conex)
                cmd.CommandType = CommandType.StoredProcedure
                cmd.Parameters.Add("@responseUrl", SqlDbType.NVarChar, -1).Value = responseUrl
                conex.Open()
                Return Convert.ToInt32(cmd.ExecuteScalar())
            End Using
        End Using
    Catch
        Return 0
    End Try
End Function

' Responds 403 and cuts off the request
Private Sub BloquearYTerminar(app As HttpApplication, razon As String)
    app.Response.Clear()
    app.Response.StatusCode = 403
    app.Response.StatusDescription = "Forbidden"
    app.Response.Write("<h1>Access Denied</h1><p>" & razon & "</p>")
    app.Response.Flush()
    app.Response.End()
End Sub

' Writes the IP to the file for the Windows Firewall script
' SyncLock because multiple threads can reach this at once
Private Sub GuardarIPParaFirewall(ip As String)
    Try
        Dim dir As String = Path.GetDirectoryName(RUTA_FICHERO_HACKERS)
        If Not Directory.Exists(dir) Then Directory.CreateDirectory(dir)
        SyncLock LockFichero
            File.AppendAllText(RUTA_FICHERO_HACKERS, ip & Environment.NewLine)
        End SyncLock
    Catch
    End Try
End Sub

' Static files: do not analyze
Private Function EsArchivoEstatico(ByVal ruta As String) As Boolean
    Select Case Path.GetExtension(ruta).ToLower()
        Case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico", ".svg", ".webp",
             ".css", ".js", ".map", ".woff", ".woff2", ".ttf", ".eot", ".txt", ".xml"
            Return True
        Case Else
            Return False
    End Select
End Function

07 — The SQL Server database

ips id int PK ip nvarchar(15) estado smallint FK contador_ infracciones int log id int PK codigo_evento descripcion fecha_hora IP nvarchar(20) whitelist id int PK ip nvarchar(15) descripcion cadenasProhibidas id int PK cadena nvarchar(255) estados_ips id smallint PK descripcion nvarchar(50) 0=Watching 1=Blacklisted
The five tables in SGPFirewall. The ips table stores the state of every IP seen; log records every event; whitelist excludes trusted IPs; cadenasProhibidas holds the attack patterns; and estados_ips is the lookup table of states.

The database creation script, cleaned up for general use:

-- Create the database (SQL Server will use its default path)
CREATE DATABASE [SGPFirewall]
GO

USE [SGPFirewall]
GO

-- Table of seen IPs and their state
CREATE TABLE [dbo].[ips] (
    [id]                   INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
    [ip]                   NVARCHAR(15)      NULL,
    [estado]               SMALLINT          NULL DEFAULT (1),
    [contador_infracciones] INT               NULL
)
GO

-- Security event log
CREATE TABLE [dbo].[log] (
    [id]           INT IDENTITY(1,1) NOT NULL,
    [codigo_evento] INT              NULL,
    [descripcion]  NVARCHAR(255)    NULL,
    [fecha_hora]   DATETIME         NULL,
    [IP]           NVARCHAR(20)     NULL,
    [timest]       TIMESTAMP        NOT NULL
)
GO

-- Trusted IPs that will never be blocked
CREATE TABLE [dbo].[whitelist] (
    [id]          INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
    [ip]          NVARCHAR(15)      NULL,
    [descripcion] NVARCHAR(100)     NULL
)
GO

-- URL patterns tied to known attacks
CREATE TABLE [dbo].[cadenasProhibidas] (
    [id]     INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
    [cadena] NVARCHAR(255)     NULL
)
GO

-- Lookup table of IP states
CREATE TABLE [dbo].[estados_ips] (
    [id]          SMALLINT      NOT NULL PRIMARY KEY,
    [descripcion] NVARCHAR(50)  NULL
)
GO

INSERT INTO estados_ips (id, descripcion) VALUES
    (0, 'Watching'),
    (1, 'Blacklisted'),
    (2, 'Whitelisted'),
    (3, 'For firewalling'),
    (4, 'Firewalled')
GO

-- Minimum whitelist: always include localhost
INSERT INTO whitelist (ip, descripcion) VALUES ('127.0.0.1', 'Localhost')
GO

The stored procedures are the key to everything working concurrently without issues. SQL Server handles locking internally, something that would be very hard to get right in the module's own code with text files or SQLite.

-- Checks whether the URL matches any attack pattern
CREATE PROCEDURE [dbo].[comparaOfending] (@responseUrl NTEXT) AS
BEGIN
    SELECT ISNULL(
        (SELECT TOP 1 id FROM cadenasProhibidas
         WHERE @responseUrl LIKE '%' + cadena + '%'),
        0)
END
GO

-- Counts login attempts from an IP in the last N seconds
CREATE PROCEDURE [dbo].[check_intentos_recientes]
    (@IP NVARCHAR(20), @Segundos INT) AS
BEGIN
    SELECT COUNT(*) FROM [log]
    WHERE IP = @IP
      AND fecha_hora > DATEADD(SECOND, -@Segundos, GETDATE())
END
GO

-- Checks whether an IP is whitelisted
CREATE PROCEDURE [dbo].[esWhitelisted] (@IP NVARCHAR(20)) AS
BEGIN
    SELECT COUNT(*) FROM whitelist WHERE ip = @IP
END
GO

-- Records a security event
CREATE PROCEDURE [dbo].[logea]
    (@codigo_evento INT, @descripcion NVARCHAR(255),
     @ip NVARCHAR(20), @fecha_hora DATETIME) AS
BEGIN
    INSERT INTO [log] (codigo_evento, descripcion, ip, fecha_hora)
    VALUES (@codigo_evento, @descripcion, @ip, @fecha_hora)
END
GO

-- Inserts or updates the state of an IP (upsert)
CREATE PROCEDURE [dbo].[set_ips]
    (@ip NVARCHAR(15), @estado SMALLINT, @num_infracciones INT) AS
BEGIN
    IF EXISTS (SELECT TOP 1 1 FROM ips WHERE ip = @ip)
        UPDATE ips
        SET estado = @estado, contador_infracciones = @num_infracciones
        WHERE ip = @ip
    ELSE
        INSERT INTO ips (ip, estado, contador_infracciones)
        VALUES (@ip, @estado, @num_infracciones)
END
GO

-- Increments the violation counter for an IP
CREATE PROCEDURE [dbo].[incrementaInfracciones] (@ip NVARCHAR(15)) AS
BEGIN
    UPDATE dbo.ips
    SET contador_infracciones = 1 + ISNULL(contador_infracciones, 0)
    WHERE ip = @ip
END
GO

The list of forbidden patterns lives in the cadenasProhibidas table. The advantage of keeping it in the database is that you can extend it without touching the code or restarting IIS. A few representative categories:

-- Webshells conocidas
INSERT INTO cadenasProhibidas (cadena) VALUES
    ('shell.php'), ('cmd.php'), ('alfa.php'), ('bypass.php'),
    ('r57.php'), ('c99.php'), ('alfashell.php')
GO

-- Configuration files that must not be reachable
INSERT INTO cadenasProhibidas (cadena) VALUES
    ('wp-config.php'), ('wp-config.php.bak'), ('.env'),
    ('/.aws/credentials'), ('.git/config'), ('web.config'), ('php.ini')
GO

-- Path traversal and system files
INSERT INTO cadenasProhibidas (cadena) VALUES
    ('/etc/passwd'), ('/etc/shadow'), ('/proc/self/environ'),
    ('c:\boot.ini'), ('c:\windows'), ('/bin/bash')
GO

-- SQL injection
INSERT INTO cadenasProhibidas (cadena) VALUES
    ('union select'), ('information_schema'), ('insert into'),
    ('xp_cmdshell'), ('concat('), (';--')
GO

-- PHP wrappers and remote execution
INSERT INTO cadenasProhibidas (cadena) VALUES
    ('php://filter'), ('php://input'), ('cmd.exe'), ('powershell.exe')
GO

-- XMLRPC (also covered by Rule 1, just in case)
INSERT INTO cadenasProhibidas (cadena) VALUES
    ('/xmlrpc.php'), ('/xmrlpc.php')
GO

08 — The PowerShell installer

Installing an HTTP module in IIS involves several steps that have to happen in the right order. The installer automates all of it. It requires PowerShell 7 or later and must run as Administrator.

# instala.ps1 - Installer for the SGPFirewall HTTP module
# Requires: PowerShell 7+, Administrator rights

$dllOrigen  = Resolve-Path ".\firewallSGP.dll"
$targetDir  = "C:\SGPFirewall"
$dllDestino = "$targetDir\firewallSGP.dll"
$moduleName = "sgpFirewall"
$dbName     = "SGPFirewall"
$appCmd     = "$env:SystemRoot\System32\inetsrv\appcmd.exe"
$globalWebConfig = "$env:windir\Microsoft.NET\Framework64\v4.0.30319\Config\web.config"

# Application user with a random password
$appUser = "SGPUser"
$appPass = "SGP" + [Guid]::NewGuid().ToString().Substring(0,8) + "!" + (Get-Random -Min 100 -Max 999)

# -- STEP 1: Detect SQL Server ------------------------------------
$sqlServices = Get-Service | Where-Object {
    ($_.Name -eq "MSSQLSERVER" -or $_.Name -like "MSSQL$*") -and $_.Status -eq "Running"
}
if ($sqlServices) {
    $svc = $sqlServices | Select-Object -First 1
    $sqlServer = if ($svc.Name -eq "MSSQLSERVER") { "localhost" }
                 else { ".\$($svc.Name.Split('$')[1])" }
} else {
    $sqlServer = Read-Host "SQL Server no detectado. Servidor (ej: localhost)"
    if ([string]::IsNullOrWhiteSpace($sqlServer)) { $sqlServer = "localhost" }
}

$sqlUser = Read-Host "Usuario Admin SQL (Enter para 'sa')"
if ([string]::IsNullOrWhiteSpace($sqlUser)) { $sqlUser = "sa" }
$sqlPassSec = Read-Host "Password de $sqlUser" -AsSecureString
$sqlPassPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
    [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sqlPassSec))

# ── PASO 2: Parar IIS ────────────────────────────────────────────
Stop-Service W3SVC -Force -ErrorAction SilentlyContinue
Stop-Service WAS   -Force -ErrorAction SilentlyContinue

# ── PASO 3: Copiar DLL ───────────────────────────────────────────
if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Path $targetDir | Out-Null }
Copy-Item -Path $dllOrigen -Destination $dllDestino -Force

# -- STEP 4: Verify the DLL implements IHttpModule ----------------
$assembly    = [Reflection.Assembly]::Load([IO.File]::ReadAllBytes($dllDestino))
$tipoModulo  = $assembly.GetTypes() |
               Where-Object { $_.GetInterface("System.Web.IHttpModule") -ne $null } |
               Select-Object -First 1
if (-not $tipoModulo) { Write-Error "La DLL no implementa IHttpModule."; exit }
$fullTypeString = "$($tipoModulo.FullName), $($assembly.FullName)"

# -- STEP 5: Register it in the GAC -------------------------------
[Reflection.Assembly]::Load("System.EnterpriseServices, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a") | Out-Null
$pub = New-Object System.EnterpriseServices.Internal.Publish
$pub.GacInstall($dllDestino)

# ── PASO 6: Crear BD, tablas, procedimientos y usuario ───────────
# (the SQL scripts from the previous section go here, run via SqlConnection)
$conn = New-Object System.Data.SqlClient.SqlConnection
$conn.ConnectionString = "Server=$sqlServer;Database=master;User Id=$sqlUser;Password=$sqlPassPlain;"
$conn.Open()
# ... ejecutar scripts SQL ...
$conn.Close()

# -- STEP 7: Register the module in IIS ---------------------------
$configFile = "$env:SystemRoot\System32\inetsrv\config\applicationHost.config"
$backup = "$configFile.$((Get-Date).ToString('yyyyMMddHHmmss')).bak"
Copy-Item -Path $configFile -Destination $backup   # Backup antes de tocar

# Remove it if it was already there (for reinstalls)
Start-Process $appCmd -ArgumentList "set config /section:system.webServer/modules /-[name='$moduleName']" -NoNewWindow -Wait
# Add the module
Start-Process $appCmd -ArgumentList "set config /section:system.webServer/modules /+""[name='$moduleName',type='$fullTypeString']""" -NoNewWindow -Wait

# ── PASO 8: Inyectar connection string en web.config global ──────
$finalConnString = "Server=$sqlServer;Database=$dbName;User Id=$appUser;Password=$appPass;"
$xml = New-Object System.Xml.XmlDocument
$xml.Load($globalWebConfig)
$backupWeb = "$globalWebConfig.$((Get-Date).ToString('yyyyMMddHHmmss')).bak"
Copy-Item -Path $globalWebConfig -Destination $backupWeb   # Backup

$root   = $xml.configuration
$csNode = $root.SelectSingleNode("connectionStrings")
if ($null -eq $csNode) {
    $csNode = $xml.CreateElement("connectionStrings")
    $root.AppendChild($csNode) | Out-Null
}
$existing = $csNode.SelectSingleNode("add[@name='SGPFirewallConn']")
if ($null -ne $existing) { $csNode.RemoveChild($existing) | Out-Null }

$entry = $xml.CreateElement("add")
$entry.SetAttribute("name", "SGPFirewallConn")
$entry.SetAttribute("connectionString", $finalConnString)
$entry.SetAttribute("providerName", "System.Data.SqlClient")
$csNode.AppendChild($entry) | Out-Null
$xml.Save($globalWebConfig)

# ── PASO 9: Arrancar IIS ─────────────────────────────────────────
try {
    Start-Service WAS  -ErrorAction Stop
    Start-Service W3SVC -ErrorAction Stop
} catch {
    # If IIS won't start, offer to restore the backups
    $resp = Read-Host "IIS no arranca. ¿Restaurar backup? (S/N)"
    if ($resp -eq "S") {
        Copy-Item -Path $backup    -Destination $configFile    -Force
        Copy-Item -Path $backupWeb -Destination $globalWebConfig -Force
        Start-Service WAS; Start-Service W3SVC
    }
}

Three important points about the installer. First, the SQL user's password ($appUser) is generated randomly on every install and never appears in any source file. Second, the installer backs up applicationHost.config and the global web.config before touching them, and offers to restore them if IIS fails to start after the install. Third, the connection string goes into the .NET Framework's global web.config (C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Config\web.config), not into any individual website's web.config, which keeps it from being reachable from the outside.

For the module to register in the GAC, the project must be signed with a strong-name certificate. In the Visual Studio project properties, under the Signing tab, enable "Sign the assembly" and generate or import a .pfx file. Without that, the GAC will reject the DLL.

09 — The Windows Firewall script

This script is deliberately short. Its only job is to read the hackers.txt file that the HTTP module keeps filling in and turn each IP into an inbound blocking rule in Windows Firewall. It runs as a scheduled task with administrator permissions, every few minutes.

# mete_ips_de_hackers_txt_a_firewall.ps1
# Run as a scheduled task, with Administrator rights

$LogFile   = "C:\SGPFirewall\hackers.txt"
$Procesados = "C:\SGPFirewall\procesados.txt"

if (Test-Path $LogFile) {
    $ips = Get-Content $LogFile

    foreach ($ip in $ips) {
        if (-not [string]::IsNullOrWhiteSpace($ip)) {
            New-NetFirewallRule `
                -DisplayName  "Bloqueo_Automatico_$ip" `
                -Direction    Inbound `
                -LocalPort    Any `
                -Protocol     TCP `
                -Action       Block `
                -RemoteAddress $ip `
                -ErrorAction  SilentlyContinue

            Add-Content $Procesados $ip
        }
    }

    # Empty the file so nothing gets processed twice
    Clear-Content $LogFile
}

The separation between the HTTP module and this script is deliberate. The IIS process doesn't have administrator permissions (and shouldn't). Adding rules to Windows Firewall requires elevated privileges. With this architecture, the module only writes to a text file — something it can do without privileges — and the script with administrator permissions reads that file from outside IIS and acts on it.

To set it up as a scheduled task:

# Register the scheduled task (run as Administrator)
$action  = New-ScheduledTaskAction -Execute "pwsh.exe" `
           -Argument "-NonInteractive -File C:\SGPFirewall\mete_ips_de_hackers_txt_a_firewall.ps1"
$trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 5) -Once `
           -At (Get-Date)
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2)

Register-ScheduledTask `
    -TaskName "SGPFirewall - Actualizar Windows Firewall" `
    -Action   $action `
    -Trigger  $trigger `
    -Settings $settings `
    -RunLevel Highest `
    -Force

With that, the system is complete. The HTTP module blocks in real time at the application level. The most dangerous IPs go into the file every time they trigger Rule 1 (XMLRPC) or Rule 2 (brute force with more than 5 attempts). The scheduled task turns them into Windows Firewall rules every five minutes, escalating those IPs to network-level blocking. From that point on, their packets get dropped before they ever reach IIS.

If you want a ready-to-install version without building the project from scratch, for now the way to ask is to just write to us directly.