Skip to content

Instantly share code, notes, and snippets.

@ktownsend-personal
Last active January 13, 2024 06:40
Show Gist options
  • Save ktownsend-personal/4d72f269d95a10acf9da57d284820ab8 to your computer and use it in GitHub Desktop.
Save ktownsend-personal/4d72f269d95a10acf9da57d284820ab8 to your computer and use it in GitHub Desktop.
How to synchronize classicASP session with ASP.NET Core 8

How to synchronize classicASP session with ASP.NET Core 8

This is related to my other gist where I use both ASP.NET Core 8 and classic ASP in the same website. You'll probably want to read that too if you're needing to do what I describe in this gist.

My original version of this was for MVC 5 several years ago. I have a separate gist of the MVC 5 version here. It worked so well I decided to refactor it for a new project I'm working on.

The code in this gist is fresh out of the oven. Everything I set out to do is working well, but not in production yet so there could be some edge cases I missed. Let me know if you run into something.

I dramatically changed the C# side of it for this new version because the coding strategy differs so dramatically between MVC 5 and ASP.NET Core 8. The classicASP side of it is mostly the same as the prior version aside from a few tweaks.

If you end up using any of this, slide a link to this gist on your code for the next developer to discover... and of course tell me about how it saved your life in the gist comments :P

Requirements

  • easily access classic ASP session state from ASP.NET Core 8
  • prevent access from outside of the website
  • optionally save changes back to classic ASP
  • optionally clear or abandon the classic ASP session
  • minimize overhead when possible

Challenges (all of them solved)

  • classic ASP does not natively do JSON
  • classic ASP has very old JScript support
  • JScript cannot use default properties
  • JScript cannot directly use collections
  • C# HttpClient may use any IP assigned to the server to originate requests, which may differ from the inbound IP for the website
    • particularly in a shared hosting environment
    • breaks IP comparison to secure requests to classic ASP to get session state
  • System.Text.Json deserialization wraps values in JsonElement instead of populating value directly when receiving type is object

Strategy

  • classic ASP page to get session info as JSON, and post modified JSON back to
    • use JSON.js on the server side to gain JSON features we take for granted from modern browsers
    • use JScript to do most of the work because it's easier to manipulate JSON in that language even though it has pitfalls with collections and default properties
    • custom function to convert collection to JScript object to make collections bearable
    • restrict requests to same IP address as website
    • use custom HTTP methods for receiving commands from C# to save changes or clear/abandon the session to add a layer of obfuscation in the event a malicious actor is able to spoof the origin IP or compromises another website on shared server
      • additional security is recommended, as you see fit
  • C# scoped service registered for dependency injection
    • fetches classic ASP session JSON only if injected
    • only fetches once per request regardless of how many injections occur
    • provides method to save back to classic ASP or clear/abandon the session

Deploying the code

Basically, you will want a subfolder on your website to hold the classic ASP related files. I used /internal to hold session.asp, JSON.js and a custom web.config with the extra HTTP verbs defined for the classic ASP ISAPI handler.

Add the ClassicASP.cs file in your ASP.NET Core project and adjust the namespace. You will need to edit the fetch URL path to match where you put session.asp around line 211. There is a lot of code in there, but using it is simple.

In your Program.cs file add a statement to register the service in your IServiceCollection with the convenient extension method: builder.Services.AddClassicASP();.

Using session from Razor Page

Using the session is easy. Just inject the scoped ClassicASP.Session service into your Razor Page or anywhere else dependency injection works and use the object. See the Test.cshtml file for full syntax example.

Let's say we injected @inject ClassicASP.Session _asp. We can access session state like this:

  1. _asp["variablename"] = "somevalue" ← set a value with a case-insensitive key
  2. _asp["variablename"] ← get a value with a case-insensitive key, or get null if missing
  3. _asp["variablename", "optional default if missing"] ← same as above, with a default value to return instead of null
  4. _asp.Data.Where(...) ← you can use Linq syntax and enumeration on a ReadOnlyDictionary

If you update any values, you can push them back to classic ASP by calling the Push method: _asp.Push(this.HttpContext, ClassicASP.PushMode.Merge);. There are also two other options: PushMode.Clear and PushMode.Abandon. All three of these correspond to the custom HTTP verbs added in web.config.

Notes

I bundled all of the related C# classes, enum and extension method into the same static class for convenience while developing, but you can pull things out into their own class files if you like. I'm sure I'll be rearranging the heck out of it soon because I feel like it's not organized right. The class nesting and mixture of static and instance classes & methods mostly achieved the access visibility I was looking for, but it feels messy. I'll update this gist if I make it better.

@page
@using yourProject.Services
@model IndexModel
@inject ClassicASP.SessionASP _asp
<div style="width:fit-content;margin:auto;">
<h1>@_asp["environment"]</h1>
<h1>@_asp["Member_name", "Not Found"]</h1>
<ul>
@foreach (var item in _asp.AsEnumerable()) {
<li>
@item.Key =
@if (item.Value is IEnumerable && item.Value is not string) {
<ul>
@foreach (var x in item.Value) {
<li>@x</li>
}
</ul>
} else {
@Html.Raw( item.Value switch { null => "{NULL}", "" => "{EMPTY}", _ => item.Value })
}
</li>
}
</ul>
</div>
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Net.Http.Headers;
using System.Dynamic;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using Headers = System.Net.Http.Headers;
namespace yourProject.Services;
public static class ClassicASP {
/// <summary>
/// Adds scoped service ClassicASP.Session to make the classic ASP session variables available.
/// </summary>
public static IServiceCollection AddClassicASP( this IServiceCollection services ) {
return services.AddScoped( sp => {
var _httpAccessor = sp.GetRequiredService<IHttpContextAccessor>();
return Session.Fetch( _httpAccessor.HttpContext! );
} );
}
/// <summary>
/// Each of these match corresponding HTTP methods that I added to the classic
/// ASP ISAPI handler in web.config to obfuscate manipulating the session.
/// </summary>
public enum PushMode { Merge, Clear, Abandon }
/// <summary>
/// Reusable JSON options, created once for lifetime of app for efficiency.
/// </summary>
private static JsonSerializerOptions? _jsonOptions;
/// <summary>
/// Accessor for reusable JSON options with auto-load on first use.
/// </summary>
public static JsonSerializerOptions JsonOptions {
//REF: good resource for System.Text.Json vs. Newtonsoft.Json:
//https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft?pivots=dotnet-8-0
get {
if (_jsonOptions is null) {
_jsonOptions = new JsonSerializerOptions {
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
_jsonOptions.Converters.Add( new ObjectToInferredTypesConverter() );
}
return _jsonOptions;
}
}
/// <summary>
/// Puts actual value in object type rather than JsonElement wrapper.
/// Useful for Dictionary<string, object> or direct object properties.
/// Just another workaround for System.Text.Json missing features we got used to with Newtonsoft.Json.
/// </summary>
private class ObjectToInferredTypesConverter : JsonConverter<object> {
public override object Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
=> reader.TokenType switch {
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.Number when reader.TryGetInt64( out long l ) => l,
JsonTokenType.Number => reader.GetDouble(),
JsonTokenType.String when reader.TryGetDateTime( out DateTime datetime ) => datetime,
JsonTokenType.String => reader.GetString()!,
JsonTokenType.StartArray => JsonSerializer.Deserialize<object[]>( ref reader, options )!,
_ => JsonDocument.ParseValue( ref reader ).RootElement.Clone()
};
public override void Write( Utf8JsonWriter writer, object objectToWrite, JsonSerializerOptions options )
=> JsonSerializer.Serialize( writer, objectToWrite, objectToWrite.GetType(), options );
}
public class Session {
/// <summary>
/// Id of the classic ASP session.
/// </summary>
[JsonInclude]
public ulong? SessionId { get; private set; }
/// <summary>
/// Internal container of ASP session variables.
/// </summary>
[JsonInclude]
private ExpandoObject SessionVars { get; set; } = new();
/// <summary>
/// Use this dynamic ReadOnlyDictionary to enumerate session variables.
/// If a session variable is an array you can enumerate it without casting due to parent accessor being dynamic.
/// If you need to modify a value, use the indexer on Session.
/// </summary>
public dynamic AsEnumerable() => SessionVars.AsReadOnly();
/// <summary>
/// Case-insensitive check if session variable exists.
/// </summary>
public bool Exists( string key ) => SessionVars.Any( kv => kv.Key.Equals( key, StringComparison.InvariantCultureIgnoreCase ) );
/// <summary>
/// Case-insensitive get value with optional default if null or missing.
/// </summary>
public object? this[string key, object? defaultValue] => this[key] ?? defaultValue;
/// <summary>
/// Case-insensitive get/set value. Returns null if missing or set null.
/// </summary>
public object? this[string key] {
get => SessionVars
.Where( kv => kv.Key.Equals( key, StringComparison.InvariantCultureIgnoreCase ) )
.Select( kv => kv.Value )
.FirstOrDefault();
set {
var matches = SessionVars.Where(kv=>kv.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase));
if (matches.Count() == 0) {
if (value is not null) {
SessionVars.TryAdd( key, value );
RemovalTrackOff( key );
}
} else {
if (value is not null) {
((IDictionary<string, object?>)SessionVars)[matches.First().Key] = value;
RemovalTrackOff( matches.First().Key );
} else {
SessionVars.Remove( matches.First().Key, out _ );
RemovalTrackOn( key );
}
}
}
}
/// <summary>
/// Tracks that a variable needs to be removed from the classic ASP session.
/// </summary>
private void RemovalTrackOn( string key ) {
if (key == "removals") return; // ignore attempts to modify our tracking list
if (this["removals"] is not List<string> removals) this["removals"] = removals = [];
if (!removals.Contains( key, StringComparer.InvariantCultureIgnoreCase )) removals.Add( key );
}
/// <summary>
/// Stops tracking a variable for removal from classic ASP session.
/// </summary>
private void RemovalTrackOff( string key ) {
if (key == "removals") return; // ignore attempts to modify our tracking list
if (this["removals"] is not List<string> removals) return;
var index = removals.FindIndex(x=>x.Equals(key, StringComparison.InvariantCultureIgnoreCase));
if (index > -1) removals.RemoveAt( index );
}
/// <summary>
/// Used by scoped service factory to initialize on first use during request to minimize calls to session.asp if not used during request.
/// </summary>
internal static Session Fetch( HttpContext context ) {
Session? session = null;
SyncClassicASP( context, async w => {
var result = await w.GetAsync("");
session = await Hydrate( result );
return result;
} );
return session ?? new Session();
}
/// <summary>
/// MERGE local changes, CLEAR or ABANDON the real classicASP session.
/// Only use this if you actually need to persist a change back to classicASP.
/// </summary>
public void Push( HttpContext context, PushMode pushMode ) {
var hrm = new HttpRequestMessage {
Method = new HttpMethod(pushMode.ToString().ToUpper()),
Content = JsonContent.Create(SessionVars)
};
var x = hrm.Content.ReadAsStringAsync().GetAwaiter().GetResult();
hrm.Content.Headers.ContentType = new Headers.MediaTypeHeaderValue( "application/json" );
SyncClassicASP( context, async w => {
var result = await w.SendAsync( hrm );
//TODO: clear and abandon are causing me trouble in the page because the standard elements from #includes aren't reconstituted until next request from classic.asp
await Hydrate( result, this ); // updated ourself with fresh JSON from the response
return result;
} );
}
/// <summary>
/// Gets JSON from response and deserializes to new or provided Session instance.
/// </summary>
private static async Task<Session> Hydrate( HttpResponseMessage response, Session? instance = null ) {
var json = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace( json )) return new Session();
var data = JsonSerializer.Deserialize<Session?>( json, ClassicASP.JsonOptions ) ?? new Session();
if (instance != null) {
instance.SessionId = data.SessionId;
instance.SessionVars = data.SessionVars;
}
return instance ?? data;
}
/// <summary>
/// Common behavior for syncing to session.asp.
/// </summary>
private static void SyncClassicASP( HttpContext context, Func<HttpClient, Task<HttpResponseMessage>> action ) {
//figure out the classic ASP session cookies (there can be multiple, so send them all)
var aspcookies = context.Request.Cookies.Keys.Where(c => c.StartsWith("ASPSESSION"));
var cookie = aspcookies.Count() > 0 ? string.Join("; ", aspcookies.Select(c => $"{c}={context.Request.Cookies[c]}").ToArray()) : null;
//build URI for the request (same server), with special handling for local-dev reverse-proxy because rewritten host hangs the whole application pool!
var host = context.Request.Host.Host == "companion7" ? "nat-dev.driftershideout.com" : context.Request.Host.Host;
var uri = new Uri($"{context.Request.Scheme}://{host}:{context.Request.Host.Port}/internal/session.asp");
//hack to not fail on localhost SSL cert: https://stackoverflow.com/a/14580179
//if (uri.Host.ToLower() == "localhost") ServicePointManager.ServerCertificateValidationCallback += (o, c, ch, er) => true;
//BETTER: copy the localhost certificate to your trusted root store: https://stackoverflow.com/a/32788265
//BEST: as above, but export localhost cert from personal certificates, without private key, then import that into trusted root certificates
//prepare HttpClient with common settings
var localIP = IPAddress.Parse(context.GetServerVariable("LOCAL_ADDR")!);
using var client = HttpClientForIP(localIP);
if (cookie != null) client.DefaultRequestHeaders.Add( HeaderNames.Cookie, cookie );
client.BaseAddress = uri;
//TODO: If we decide to try and re-use a single instance to align with best-practices we would need to also ensure cookie header is set on HttpRequestMessage
// rather than on the HttpContext instance. To enable re-use we can use a private static variable and ??= the variable with a call to HttpClientForIP().
// The only concern with a long-lived instance is not getting DNS updates, but that's not an issue here since we use IP. Apparently the issue with newing
// up instances constantly is socket exhaustion because sockets are not immediately released after dispose. Not a big concern with our low traffic rates.
// REF: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests
//caller can do what they want with the client now
var response = action(client).GetAwaiter().GetResult();
// NOTE: Task.GetAwaiter().GetResult() throws actual exceptions, whereas .Result() or .Wait() wraps the exception:
// REF: https://youtu.be/zhCRX3B7qwY?si=Mlq9SguOuLQoMWAz&t=1899 (the whole video is good advice, if you haven't seen it)
// NOTE: we didn't make this function async on purpose
var x = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode) {
//TODO: show the error someplace
throw new Exception( $"Status {response.StatusCode}: {response.Content.ReadAsStringAsync().GetAwaiter().GetResult()}" );
}
//forward any new cookies to our response to "keep" the new session if one was created during the request to session.asp
response.Headers?
.Where( h => h.Key.Equals( "Set-Cookie", StringComparison.OrdinalIgnoreCase ) )
.ToList()
.ForEach( h => context.Response.Headers.Append( h.Key, new Microsoft.Extensions.Primitives.StringValues( (string?[]?)h.Value ) ) );
//example Set-Cookie for a classic ASP session
//ASPSESSIONIDQEARCSAS=EBBBPDCBHBPENBBOLMCEHOOB; secure; path=/
//cookies can also have expires=xxx; max-age=xxx; and domain=xxx;
//NOTE: we get a separate Set-Cookie header for each cookie being set
}
/// <summary>
/// This helper gives us an HttpClient for a specific origin IP.
/// Our production environment is a shared host and we need to ensure our origin IP is
/// the same IP our requests are received on to match what session.asp is looking for.
/// Use IHttpClientFactory instead to get an HttpClient if you don't need this behavior.
/// </summary>
//Copied from here: https://stackoverflow.com/a/66681784
private static HttpClient HttpClientForIP( IPAddress ip ) {
if (IPAddress.Any.Equals( ip )) return new HttpClient();
var handler = new SocketsHttpHandler();
handler.ConnectCallback = async ( context, cancellationToken ) => {
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
socket.Bind( new IPEndPoint( ip, 0 ) );
socket.NoDelay = true;
try {
await socket.ConnectAsync( context.DnsEndPoint, cancellationToken ).ConfigureAwait( false );
return new NetworkStream( socket, true );
} catch {
socket.Dispose();
throw;
}
};
return new HttpClient( handler );
}
}
}
<%' you don't need these, but I am showing them for completeness because there is a comment later that mentions them %>
<!-- #include virtual="/wwwroot/inc/asp-preamble.asp" -->
<!-- #include virtual="/wwwroot/inc/environment.asp" -->
<!-- #include virtual="/wwwroot/inc/maintenance.asp" -->
<%
'we have to call the javascript from inside < % % > tags because of the script execution order:
' 1. <script runat="server" language="not default language">
' 2. < % % >
' 3. <script runat="server" language="same as default language">
' If the javascript were to run first, the session variables set by the includes won't have happened yet!
run
%>
<script language="javascript" runat="server" src='json2.js'></script>
<script language="javascript" runat="server">
/*
Author: Keith Townsend, Feb 7, 2021
This page is for ASP.NET on same server to read and update the classic ASP session state while we have them running side by side.
Inspiration for this technique from: https://searchwindevelopment.techtarget.com/tip/Share-session-state-between-ASP-and-ASPNET-apps
NOTE: It is critical that the REMOTE_ADDR vs. LOCAL_ADDR comparison works as intended to ensure only the server can call this page.
NOTE: I had to update IIS to allow the custom MERGE, CLEAR and ABANDON request methods:
=> Open IIS => Open Handler Mappings => Select "ASPClassic" => Request Restrictions => Verbs => select "All verbs", or add the ones you want to the list of accepted verbs.
=> Alternatively, you can configure this in web.config (<configuration> => <system.webServer> => <handlers>) with a <remove name="ASPClassic"> and an <add> to put it back in.
=> If that doesn't work, try also removing WebDav because I saw mention that can interfere (PUT and DELETE were mentioned).
NOTE: In JScript, the Request.ServerVariables collection returns an object with Count and Item properties (Item is default property, but that only works in VBScript)
We need json2.js because server-side JScript is very old and doesn't have the JSON object built-in like modern browsers do.
NOTE: If using JSON.parse() in VBScript, be aware that arrays are JScript arrays and you need dot syntax to access them (e.g., parsedObj.[0]).
SOURCE: https://github.com/douglascrockford/JSON-js/blob/master/json2.js
*/
function run() {
// IMPORTANT: it is critical that the remote and local address is the same to avoid external hacking
if (Request.ServerVariables("REMOTE_ADDR").item === Request.ServerVariables("LOCAL_ADDR").item) {
// modification actions
switch (Request.ServerVariables("REQUEST_METHOD").item.toUpperCase()) {
case "MERGE":
//merge JSON with existing session data => fine in most cases where not removing a variable
SetSessionFromBodyJSON();
break;
case "CLEAR":
//clear session, but keep it alive
Session.Contents.RemoveAll();
break;
case "ABANDON":
//kill session
Session.Contents.RemoveAll(); //clearing session manually to avoid sending the values; see note on next line
Session.Abandon(); //IMPORTANT: current session is still available to scripts after this call until next page is requested
break;
}
//provide session data to caller
//NOTE: after CLEAR or ABANDON, any automatic session values done by #include scripts will not yet be re-added; do a fresh GET to receive them
//NOTE: ABANDON doesn't reset session until after this request finishes, so you won't see the SessionId change until next request
//NOTE: extra properties you add to wrapper can be useful for debugging without affecting session state, such as viewing ServerVariables state
var wrapper = {
// ServerVariables: CollectionToObject(Request.ServerVariables, function(i){return i.Item}), //"default" property to get value from a Request.ServerVariables object is .Item
SessionVars: CollectionToObject(Session.Contents), //no default property to worry about on session items
SessionId: Session.SessionId
}
Response.Clear();
Response.ContentType = "application/json";
Response.Write(JSON.stringify(wrapper, null, 2));
}
}
//enumerating COM collections is a PITA, so I made a function to push the items to an object
function CollectionToObject(collection, mapper, target) {
var map = target ? target : {}; //fresh object if no target given to mutate
for (var objEnum = new Enumerator(collection); !objEnum.atEnd(); objEnum.moveNext()) {
var key = objEnum.item();
var val = collection(key);
//try to handle VBScript arrays
if (typeof (val) === 'unknown')
try {
val = (new VBArray(val)).toArray();
} catch (e) {
val = "{unknown type}";
}
//mapper callback gives caller a way to access a property to get the value if needed (looking at you Request.ServerVariables("key").Item)
var mapped = typeof mapper === "function" ? mapper(val) : val;
if (mapped !== undefined) map[key] = mapped;
}
return map;
}
function SetSessionFromBodyJSON() {
var data = BodyJSONAsObject();
if (!data) return;
if (data.removals) {
for (index in data.removals)
Session.Contents.Remove(data.removals[index]);
delete data.removals;
}
for (key in data)
Session.Contents(key) = data[key];
}
function BodyJSONAsObject() {
if (Request.TotalBytes > 0) {
var lngBytesCount = Request.TotalBytes;
if (lngBytesCount > 100000) return null; //simple sanity check for length; come back to this if having truncation issues
var data = BytesToStr(Request.BinaryRead(lngBytesCount));
return JSON.parse(data);
}
}
//used for getting string from request body; found here: https://stackoverflow.com/a/9777124
function BytesToStr(bytes) {
var stream = Server.CreateObject("Adodb.Stream")
stream.type = 1 //adTypeBinary
stream.open
stream.write(bytes)
stream.position = 0
stream.type = 2 //adTypeText
stream.charset = "utf-8"
var sOut = stream.readtext()
stream.close
return sOut
}
</script>
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<handlers>
<!--
Adding custom verbs requires removing and re-adding the IsapiModule.
This web.config is directly in the same folder as session.asp so we can target the custom HTTP verbs to just this folder.
-->
<remove name="ASPClassic" />
<add name="ASPClassic" path="*.asp" verb="GET,MERGE,CLEAR,ABANDON" modules="IsapiModule" scriptProcessor="%windir%\system32\inetsrv\asp.dll" resourceType="File" requireAccess="Script" />
</handlers>
</system.webServer>
</configuration>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment