miércoles, 30 de noviembre de 2011

De vuelta a lo básico, codificar html, javascript y url.

Aunque puede resultar algo obvio a muchos, hoy quiero hablar sobre algo que de vez en cuando me ha dado más de un problema. Estoy hablando de estos maravillosos códigos de producto (o de cliente o de lo que sea) que puede incluir caracteres no habituales como la comilla simple, comilla doble, etc.

Imaginemos que tenemos un producto con un identificador que incluye tanto una comilla simple como una comilla doble, por ejemplo, a partir de ahora nuestro código maldito será “sergio’s” (incluyendo las 2 comillas dobles y la comilla simple).

Aunque lo iremos viendo poco a poco, te adelanto que en este post intentaré resolver como trabajar con este código en HTML, Javascript y como QueryString en una Url.

Si no nos metemos en ningún jardín y utilizamos ASP.NET para renderizar nuestro código HTML, lo cierto es que no tendremos ningún problema. Veamos un ejemplo sencillo donde se vuelca nuestro  código en una caja de texto.

El código de servidor ASP.NET es el siguiente:

txtIdProducto.Text = """sergio's"""


Y el HTML generado es:

<input name="txtIdProducto" type="text" value="&quot;sergio&#39;s&quot;" id="txtIdProducto" />

Si nos centramos en el atributo value, podemos ver como ciertos caracteres han sido sustituidos por nombres de entidad HTML:

·         La comilla doble pasó a ser &quot;

·         La comilla simple pasó a ser &#39;

Esto ha sucedido porque cuando se ha renderizado nuestra caja de texto se ha llamado automáticamente al método Server.HtmlEncode para su atributo value.

Es decir, Server.HtmlEncode("""sergio's""") devuelve &quot;sergio&#39;s&quot;

Y por el contrario, Server.HtmlDecode("&quot;sergio&#39;s&quot;") devuelve "sergio's"

La referencia de las entidades HTML (ya sea con nombre o numérico) las puedes encontrar aquí, aunque una simple búsqueda en google te arrojará decenas de resultados.

Para que te hagas una idea y veas que cualquier carácter puede ser representado con una entidad HTML, podríamos escribir lo siguiente (que es totalmente ilegible pero es igualmente válido):

<input type="text" value="&quot;&#115;&#101;&#114;&#103;&#105;&#111;&#39;&#115;&quot;" />

En este ejemplo, además de las comillas dobles y la comilla simple, hemos sustituido el resto de caracteres por su entidad numérico HTML:

·         . &quot;

·         s. &#115;

·         e. &#101;

·         r. &#114;

·         g. &#103;

·         i. &#105;

·         o. &#111;

·         . &#39;

·         s. &#115;

·         . &quot;

Lógicamente, asumo que no te vas a volver loco y a partir de ahora te vas a poner a escribir todo tu código HTML a través de entidades, pero en cualquier caso, podrías.

La conclusión cuando estamos trabajando con código HTML, es que cualquier valor de cualquier atributo de cualquier elemento (¿demasiado cualquier quizás?) tiene que ir acompañado de Server.HtmlEncode. Es decir, imagina que estás construyendo una cadena en servidor con código HTML:

' Mal

' Devuelve <input type='text' value='"sergio's"' />

Dim html As String = String.Format("<input type='text' value='{0}' />", """sergio's""")

 

' Bien

' Devuelve <input type='text' value='&quot;sergio&#39;s&quot;' />

html = String.Format("<input type='text' value='{0}' />", Server.HtmlEncode("""sergio's"""))

 

Aunque sobra decirlo, si recuperamos el valor de nuestro input desde javascript, siempre obtendremos el valor original:

alert(document.getElementById("txtIdProducto").value);

 

clip_image001[4]

Si nos centramos ahora en Javascript, es necesario complicar un poco más nuestro código de producto para que los ejemplos sean más completos. De esta forma, ahora nuestro código de producto pasará a ser “ser\gio’s” (hemos incluido una barra inversa).

Ahora imaginemos que desde ASP.NET queremos generar un saludo con nuestro código de producto al cargar la página. Un posible código sería el siguiente:

Dim idProducto As String = """ser\gio's"""

Dim js As String = String.Format("alert('{0}');", idProducto)

ClientScript.RegisterClientScriptBlock(Me.GetType(), "Page_Load", js)

 

Y la salida que obtenemos es… que no hay alert! Pero bueno, ¿Dónde está mi alert?

Si vemos el código HTML de la página, tiene el siguiente código:

<script type="text/javascript">

//<![CDATA[

alert('"ser\gio's"');//]]>

</script>

Está claro que el mensaje de nuestro alert no está bien construido, así que toca lidiar con ello.

Para esta situación, yo suele escribir mi propia función donde reemplazo ciertos caracteres de la cadena entrante por sus caracteres de escape Javascript:

    Public Shared Function JavascriptEncode(ByVal s As String) As String

        s = s.Replace("\", "\\")

        s = s.Replace("'", "\'")

        s = s.Replace("""", "\""")

        Return s

    End Function

 

·         \ se reemplaza por \\

·         ‘ se reemplaza por \’

·         “ se reemplaza por \”

Como es de esperar, esta función seguro que no contempla ni la mitad de los casos que te pueden ocurrir, pero al menos es un principio. De hecho, le da igual si el delimitador de cadena es la comilla simple o la comilla doble (yo personalmente utilizo indistintamente ambos delimitadores en mi código Javascript cuando trabajo con cadenas).

Si ahora llamamos a la función desde el código de servidor ASP.NET:

Dim idProducto As String = """ser\gio's"""

Dim js As String = String.Format("alert('{0}');", JavascriptEncode(idProducto))

ClientScript.RegisterClientScriptBlock(Me.GetType(), "Page_Load", js)


El HTML resultante ya sí es correcto:

<script type="text/javascript">

//<![CDATA[

alert('\"ser\\gio\'s\"');//]]>

</script>

clip_image002[4]

Por último, hablaremos sobre este tipo de situaciones cuando trabajamos con nuestro código de producto en una Url como parte de la QueryString.

De nuevo, vamos a complicar nuestro código de producto (esta es la última vez, lo juro). Ahora será “&ser\gio’s” (le hemos incluido un ampersand)

Aquí tenemos que contemplar dos escenarios (al menos son con los que yo trabajo habitualmente):

·         Generar la url desde Javascript

·         Generar la url desde ASP.NET, es decir, desde el servidor

Si creamos la url desde Javascript:

var id = document.getElementById("txtIdProducto").value;

var url = "VerProducto.aspx?IdProducto=" + id;

alert(url);

 

clip_image003[4]

 

Está claro que hay no funcionará porque, para empezar, el carácter & es el delimitador de campos en una QueryString y entonces ¿Qué tengo en la url?

clip_image004[4]

Pues resulta que ASP.NET dice tener 2 campos. El segundo campo no tiene nombre y el primer campo no tiene valor… vamos, que la url está muy mal construida.

Para solucionarlo, basta con utilizar la función nativa de Javascript, encodeURIComponent.

var id = document.getElementById("txtIdProducto").value;

var url = "VerProducto.aspx?IdProducto=" + encodeURIComponent(id);

 

clip_image005[4]

clip_image006[4]

Ahora todo funciona correctamente, porque se han sustituido en la url los caracteres reservados por sus equivalentes caracteres de escape.

·         “ por %22

·         & por %26

Ya por último, si nos centramos en la generación de la url desde código de servidor ASP.NET, habrá que utilizar la función Server.UrlEncode.

Dim url As String = "VerProducto.aspx?IdProducto={0}"

url = String.Format(url, Server.UrlEncode("""&sergio's"""))


Que devolverá la cadena:

VerProducto.aspx?IdProducto=%22%26sergio%27s%22

Lo cierto es que este post, da para mucho que escribir, pero al menos espero haber sentado ciertas bases para que los códigos indeseables de productos no te quiten el sueño.

Un saludo!

viernes, 25 de noviembre de 2011

Cómo capturar el tráfico de red en ASP.NET

En nuestra empresa hemos desarrollado una aplicación que queremos comercializar en un modelo Saas y desplegarla en Windows Azure.

Uno de los mayores problemas que nos encontramos en este escenario, es el de conocer qué tráfico de red genera cada usuario, para poder después repercutir ciertos costes de la infraestructura al cliente y empresa adecuada.

Siendo así, hemos pensando en desarrollar una herramienta que sea capaz de capturar el tráfico tanto de entrada como de salida de la aplicación y guardar un registro de esta información.

En principio, cualquiera podría pensar que estamos reinventado la rueda y para que obtener esta información existen los log de servidor de IIS, pero el desarrollo ad-hoc de esta aplicación se sustenta en las siguientes premisas:

  • Si desplegamos nuestra aplicación en Windows Azure, aparentemente no tendremos acceso a los logs de IIS (lo cierto es que no estoy seguro de esta afirmación, pero creo estar en lo cierto). Pues me confirman que sí, sí se puede tener acceso a logs de IIS, tanto en la máquina local accediendo a través de Remote Destkop como transfiriendo los logs al Blob Store (gracias a Carlos Alfaya, de Nextel, por la información).
  • Aunque tuviéramos acceso a los log de IIS, resultaría complicado saber qué petición corresponde a qué usuario. Es decir, el usuario actual de la petición (según el sistema de seguridad de Membership), se guarda en la cookie .ASPXAUTH y además esta cookie esta encriptada por defecto, así que probablemente sería harto complicado después desencriptar la cookie para saber a qué usuario pertenece la petición.
  • Como queremos que nuestra aplicación sea un Saas puro (soñar es gratis), sólo queremos tener una instancia de la aplicación para dar soporte a distintas empresas y por ende a usuarios de estas empresas. Siendo así, la información de tráfico que pudiéramos obtener desde Windows Azure sería siempre relativa a la instancia y no podríamos discriminar el consumo por cliente.

Una vez habiendo explicado qué motivos nos han conducido a desarrollar nuestra propia solución, es el momento de explicar que información queremos recabar exactamente:

  • Tamaño de la petición.
  • Tamaño de la respuesta.
  • Usuario que generó la petición y respuesta.

Para lograr esto, hemos optado por crear un módulo HTTP.

El primer problema que nos surge es que queremos capturar todo el tráfico con independencia del tipo de recurso solicitado. Esto es que nos da igual si el usuario solicitó un fichero .aspx o un fichero .jpg, un recurso dinámico o un recurso estático, ambos deberían ser procesados por nuestro módulo.

En esta situación es indispensable conocer la versión de IIS donde será desplegada nuestra aplicación. Esto es así porque sólo con IIS 7.0 o superior y con el modelo de canalización integrada, todas las peticiones y con independencia de su tipo, serán gestionadas por ASP.NET y provocarán la ejecución de nuestro módulo.

Aunque en IIS 6.0 podríamos mapear extensiones al filtro ISAPI de ASP.NET, llegamos a la conclusión de que mejor desplegar nuestra aplicación en un Windows Server 2008 y olvidarnos de esta configuración extra.

Cabe mencionar que en cualquier caso, esto es algo que no debería preocupar a nuestro módulo y que sería un hecho transparente al mismo.

Volviendo a la implementación del módulo, éste sería mucho más sencillo si pudiéramos recuperar la cabecera Content-Length desde ASP.NET, pero parecer ser que esta cabecera la crea automáticamente IIS y ASP.NET no conoce de su existencia.

Igualmente, podríamos haber activado en IIS la compresión HTTP, tanto para recursos estáticos como para recursos dinámicos, y de nuevo este hecho es transparente para ASP.NET que no sabe si el recurso solicitado va a ser o no comprimido por IIS.

A este respecto, cabe mencionar que no deberíamos confundir que el cliente acepte la compresión HTTP (según la cabecera Accept-Encoding), a que, efectivamente, se vaya a comprimir la respuesta. Es decir, yo puedo con mi IE aceptar compresión pero el servidor comprimirá o no, eso yo no lo puedo saber.

Otra afirmación que me gustaría compartir, porque no estoy seguro de la misma, es que un cliente nunca comprime la petición. Es decir, un PostBack nunca sube al servidor comprimido con GZIP, son las respuestas del servidor las que pueden o no estar comprimidas, pero la petición nunca.

Como seguro que ya te estás cansando de la previa, vamos directamente a la implementación.

Lo primero es definir una tabla donde guardar la información:

image

  • BandwidthId.
    • Autonúmerico.
  • ApplicationPath.
    • Request.ApplicationPath.
  • Url.
    • Request.RawUrl
  • UserName.
    • User.Identity.Name
  • Request.
    • Request.ContentLength
  • Response.
    • Calculado. La madre del cordero.
  • Compressed.
    • Indica si la respuesta se devolvió comprimida.
  • CompressedResponse.
    • Calculado. La otra madre del cordero.
  • CreatedDate.
    • Fecha de creación automática.
CREATE TABLE [dbo].[Bandwidth](
	[BandwidthId] [int] IDENTITY(1,1) NOT NULL,
	[ApplicationPath] [nvarchar](256) NOT NULL,
	[Url] [nvarchar](max) NOT NULL,
	[UserName] [nvarchar](256) NULL,
	[Request] [int] NOT NULL,
	[Response] [int] NOT NULL,
	[Compressed] [bit] NOT NULL,
	[CompressedResponse] [int] NOT NULL,
	[CreatedDate] [datetime] NOT NULL,
 CONSTRAINT [PK_Bandwidth] PRIMARY KEY CLUSTERED 
(
	[BandwidthId] ASC
)WITH (PAD_INDEX  = OFF, STATISTICS_NORECOMPUTE  = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS  = ON, ALLOW_PAGE_LOCKS  = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Bandwidth] ADD  CONSTRAINT [DF_Bandwidth_Compressed]  DEFAULT ((0)) FOR [Compressed]
GO
ALTER TABLE [dbo].[Bandwidth] ADD  CONSTRAINT [DF_Bandwidth_CreatedDate]  DEFAULT (getdate()) FOR [CreatedDate]
GO

El código del módulo es el siguiente, donde a grandes rasgos:



  • Crearemos un módulo HTTP para participar del pipeline de ASP.NET.
  • Crearemos un nuevo filtro en la respuesta con el propósito exclusivo de ir acumulando el total de bytes escritos en el OutputStream.
  • Según configuración, también se guardará una copia del OutputStream en un nuevo Stream del tipo MemoryStream que nos permitirá después jugar con ella sin las limitaciones impuestas por OutputStream.
  • Finalmente, grabaremos un registro con la información obtenida.
  1: Imports System.IO
  2: Imports System.IO.Compression
  3: Imports System.Configuration
  4: Imports System.Web
  5: 
  6: Public Class ContentLengthStream
  7:     Inherits MemoryStream
  8: 
  9:     Private output As Stream
 10:     Private copyOutput As Boolean
 11:     Private totalCount As Integer
 12:     Private memoryStream As MemoryStream
 13: 
 14:     Public Sub New(ByVal output As Stream, ByVal copyOutput As Boolean)
 15:         Me.output = output
 16:         Me.copyOutput = copyOutput
 17:         ' Sólo si copyOutput, se crea el objeto memoryStream
 18:         If copyOutput Then
 19:             memoryStream = New MemoryStream()
 20:         End If
 21:     End Sub
 22: 
 23:     Public Overrides Sub Write(buffer() As Byte, offset As Integer, count As Integer)
 24:         totalCount += count
 25:         HttpContext.Current.Items("TotalCount") = totalCount
 26:         ' Sólo si copyOutput, se escribe en el objeto memoryStream
 27:         If copyOutput Then
 28:             memoryStream.Write(buffer, offset, count)
 29:             HttpContext.Current.Items("OutputStream") = memoryStream
 30:         End If
 31:         output.Write(buffer, offset, count)
 32:     End Sub
 33: End Class
 34: 
 35: Public Class Logger
 36:     Implements IHttpModule
 37: 
 38:     Private Function GetCompressionLength() As Integer
 39:         Dim targetStream As New MemoryStream()
 40:         Dim gzip As New GZipStream(targetStream, CompressionMode.Compress)
 41:         Dim outputStream As MemoryStream = CType(HttpContext.Current.Items("OutputStream"), MemoryStream)
 42:         Dim buffer() As Byte = outputStream.ToArray()
 43:         gzip.Write(buffer, 0, buffer.Length)
 44:         Return targetStream.Length
 45:     End Function
 46: 
 47:     Private Function IsCompressedFileExtension() As Boolean
 48:         Dim extension As String = VirtualPathUtility.GetExtension(HttpContext.Current.Request.RawUrl)
 49:         If extension.StartsWith(".") Then
 50:             extension = extension.Substring(1)
 51:         End If
 52:         Dim q As Integer =
 53:             Aggregate r In ConfigurationManager.AppSettings.Item("BandwidthCompressedFileExtensions").Split(",")
 54:             Where String.Equals(r, extension, StringComparison.OrdinalIgnoreCase)
 55:             Into Count()
 56:         Return If(q = 1, True, False)
 57:     End Function
 58: 
 59:     Public Sub Dispose() Implements System.Web.IHttpModule.Dispose
 60:     End Sub
 61: 
 62:     Public Sub Init(context As System.Web.HttpApplication) Implements System.Web.IHttpModule.Init
 63:         AddHandler context.PostReleaseRequestState, AddressOf PostReleaseRequestState
 64:         AddHandler context.PreSendRequestHeaders, AddressOf PreSendRequestHeaders
 65:     End Sub
 66: 
 67:     Protected Sub PostReleaseRequestState(sender As Object, e As System.EventArgs)
 68:         Dim copyOutput As Boolean
 69:         ' Si el fichero va a ser devuelto comprimido y se quiere calcular el tamaño comprimido
 70:         If IsCompressedFileExtension() AndAlso ConfigurationManager.AppSettings.Item("BandwidthComputeCompression") Then
 71:             copyOutput = True
 72:         End If
 73:         HttpContext.Current.Response.Filter = New ContentLengthStream(HttpContext.Current.Response.Filter, copyOutput)
 74:     End Sub
 75: 
 76:     Protected Sub PreSendRequestHeaders(sender As Object, e As System.EventArgs)
 77:         If HttpContext.Current.Response.StatusCode = 200 Then
 78: 
 79:             Dim requestContentLength As Integer = HttpContext.Current.Request.ContentLength
 80:             Dim responseContentLength As Integer = Convert.ToInt32(HttpContext.Current.Items("TotalCount"))
 81:             Dim compressedResponseContentLength As Integer = 0
 82: 
 83:             If requestContentLength > 0 OrElse responseContentLength > 0 Then
 84: 
 85:                 Dim userName As String = String.Empty
 86:                 If HttpContext.Current.User.Identity.IsAuthenticated Then
 87:                     userName = HttpContext.Current.User.Identity.Name
 88:                 End If
 89: 
 90:                 Dim compressed As Boolean = IsCompressedFileExtension()
 91: 
 92:                 If compressed AndAlso ConfigurationManager.AppSettings.Item("BandwidthComputeCompression") AndAlso responseContentLength > 0 Then
 93:                     compressedResponseContentLength = GetCompressionLength()
 94:                 End If
 95: 
 96:                 Using cnn As New SqlClient.SqlConnection(ConfigurationManager.AppSettings.Item("BandwidthConnectionString"))
 97:                     cnn.Open()
 98:                     Dim cmdText As String = "INSERT INTO Bandwidth (ApplicationPath, Url, UserName, Request, Response, Compressed, CompressedResponse) VALUES (@ApplicationPath, @Url, @UserName, @Request, @Response, @Compressed, @CompressedResponse)"
 99:                     Using cmd As New SqlClient.SqlCommand(cmdText, cnn)
100:                         cmd.Parameters.AddWithValue("@ApplicationPath", HttpContext.Current.Request.ApplicationPath)
101:                         cmd.Parameters.AddWithValue("@Url", HttpContext.Current.Request.RawUrl)
102:                         cmd.Parameters.AddWithValue("@UserName", If(userName = String.Empty, Convert.DBNull, userName))
103:                         cmd.Parameters.AddWithValue("@Request", requestContentLength)
104:                         cmd.Parameters.AddWithValue("@Response", responseContentLength)
105:                         cmd.Parameters.AddWithValue("@Compressed", compressed)
106:                         cmd.Parameters.AddWithValue("@CompressedResponse", compressedResponseContentLength)
107:                         cmd.ExecuteNonQuery()
108:                     End Using
109:                 End Using
110:             End If
111:         End If
112: 
113:         If Not HttpContext.Current.Items("OutputStream") Is Nothing Then
114:             CType(HttpContext.Current.Items("OutputStream"), MemoryStream).Dispose()
115:             HttpContext.Current.Items("OutputStream") = Nothing
116:         End If
117:     End Sub
118: End Class

Los parámetros de configuración que utiliza nuestro módulo a través de appSettings del fichero web.config son los siguientes:



  • BanwidthConnectionString.

    • Cadena de conexión contra un servidor Sql Server donde se guardará la información obtenida.

  • BandwidthCompressedFileExtensions.

    • Lista de extensiones separadas por comas, que sabemos van a ser devueltas comprimidas por IIS. Reitero que esta información es necesaria porque desde el módulo de ASP.NET no somos capaces de saber si el recurso va a ser o no devuelto comprimido. Siendo así, hay que especificarlo manualmente.

  • BandwidthComputeCompression.

    • Indica si cuando encontramos un recurso que va ser devuelto comprimido (según BandwidthCompressedFileExtensions), nuestro módulo debe comprimir la copia de OutputStream para grabar el dato de la columna CompressedResponse.

Esta claro que el parámetro más agresivo es BandwidthComputeCompression, que en el caso de estar activo, fuerza al módulo a crear una copia de Response.OutputStream en memoria para después comprimirla con GZip. Exactamente no sabría calibrar el impacto de esta tarea por cada petición, pero está claro que entre hacerlo o no hacerlo, es más óptimo no hacerla.


En cualquier caso, siempre se podría desactivar este parámetro y luego hacer un ratio aproximado de cuanto sería comprimida la respuesta a través de una consulta Sql.


Lo que quiero decir con aproximado es que tengo claro que esta solución no es un capturador de tráfico definitivo por lo siguientes motivos:



  • Sólo captura tráfico del cuerpo de la petición. Esto es que las cabeceras de la respuesta no están incluidas.
  • Si comprimimos la respuesta, he visto con la ayuda de Fiddler, que mi compresión no es el mismo valor que la compresión que realmente devuelve Fidderl, de hecho ni el propio Fiddler arroja el mismo valor si cambiamos entre “No Compression” y “GZip Encoding”. Por ejemplo:

Respuesta original (362 bytes)


image


Ahora pulso en “No Compression” y obtengo 337 bytes


image


Ahora pulso de nuevo en GZIP Encoding y obtengo… 250 bytes! (no 362 como era de esperar)


image


Esta claro que en 2 días no nos vamos a hacer un super-módulo sniffer de tráfico HTTP, pero al menos queremos tener una información muy aproximada de que consumo de tráfico genera que usuario.


En cualquier caso, este módulo está aún en fase beta y el tiempo nos dará o nos quitará la razón.


Por último, un fichero web.config válido para hacer ejecutar el módulo sería el siguiente:

<?xml version="1.0"?>
<configuration>
  <appSettings>
    <add key="BandwidthConnectionString" value="Data Source=(local);Initial Catalog=Bandwidth;Persist Security Info=True;User ID=sa;Password=******"/>
    <add key="BandwidthComputeCompression" value="True"/>
    <add key="BandwidthCompressedFileExtensions" value="aspx,js,htm"/>
  </appSettings>
  <system.web>
    <compilation strict="false" explicit="true" targetFramework="4.0"/>
  </system.web>
  <system.webServer>
    <modules>
      <add name="Bandwidth" type="Bandwidth.Logger"/>
    </modules>
  </system.webServer>
</configuration>

Un saludo!