Taking over a legacy .NET web estate, closing its security holes and moving it onto single sign-on

A client's public website and online store, ASP.NET Core Razor Pages apps built over several years by a previous developer, came to us as they stood. Secrets were committed to source, pages wrote stored user text straight into the HTML and each app had its own idea of who a user was. We secured both, moved them onto containers in Azure that still talk to the client's own database servers through encrypted tunnels, and made our identity provider the only way to sign in.

Locality
North America and Europe
Disciplines
Identity and security, Systems integration
Stack
  • ASP.NET Core
  • Razor Pages
  • C#
  • Azure Container Apps
  • Key Vault
  • OpenID Connect
  • MySQL
  • WireGuard

At a glance

  • 975

    commits of inherited code

  • 86

    Razor pages and views

  • 4

    secrets taken out of source

  • 348

    tests holding the fixes in place

The challenge

The website and the store had been written by one developer over several years and were handed to us as they stood. Both ran on ASP.NET Core Razor Pages against a shared MySQL database, each kept its own accounts, and both went live through a pipeline that pushed to IIS.

Reading the code before changing any of it turned up the usual inheritance: a production connection string, an API token, an SMTP app password and a captcha secret committed to source, pages that wrote stored user text straight into the HTML, actions that changed data on a plain GET, and binaries checked into the repo where packages should have been.

Architecture

Legacy .NET estate takeover architectureArchitecture diagram with 7 components. Members and staff to Website. Members and staff to Online store. Website to Identity provider (OIDC + PKCE). Website to Key Vault. Online store to Private bridge. Private bridge to Database servers.Azure Container AppsOn premisesOIDC + PKCEMembers and staffWebsiteASP.NET Core Razor PagesOnline storeASP.NET Core Razor PagesPrivate bridgeWireGuard tunnelsDatabase serversMySQL, private namesIdentity providerThe only sign-inKey VaultEvery secret

Swipe to explore the diagram

Both apps run as containers, read their secrets from Key Vault and reach the on-premises database servers only through the WireGuard bridge, encrypted between Azure and the client's site. The website signs people in through the identity provider, and the store reads contacts from it with a read-only machine client.

What we built

Security

  • Four committed secrets removed: the connection string and API token moved to Key Vault, the SMTP password went with SMTP itself in favour of certificate-based Microsoft Graph mail, and the captcha was replaced rather than rotated
  • Stored cross-site scripting closed at render, so rows already in the database are covered too, with tests that push script payloads through the affected pages
  • Links that changed data on a plain GET turned into antiforgery-protected POSTs, and an unused tool with an anonymous start and stop link removed
  • Per-account rate limits and duplicate checks on the support form, after a scripted account posted 59 tickets in 87 seconds
  • Every database value written into JavaScript properly encoded, after an apostrophe in one name broke add-to-cart on every store page for about 26 hours

Platform

  • Moved from an IIS deployment pipeline to Azure Container Apps, deployed on merge, with pull requests gated on a build
  • Committed DLLs replaced with versioned packages from a private feed
  • Login and antiforgery keys persisted, so a restart no longer signs everyone out, and uploads moved to durable storage

Hybrid Azure and on-premises

  • Database traffic from the Container Apps environment carried to the on-premises database servers over WireGuard tunnels and a forwarding bridge, addressed by private names, never across the open internet
  • Connection strings rewritten to those private names and held in Key Vault, so the apps never see a public database address
  • The client's local systems and the web apps share the same databases, so they work as one integrated suite

Single sign-on

  • The website made an OpenID Connect relying party: authorization code with PKCE, state and nonce checked, and its client secret in Key Vault
  • Shipped nine days early behind a mode setting, then switched on across the website, the store and a third app on the same day
  • Since the switch the website creates no accounts and holds no passwords. Registration, reset and change all belong to the identity provider
  • The store reads contacts through a read-only machine client

Engineering decisions

Read it before you touch it

A security census of the inherited code came first. Fixes went in order of exposure, and each one arrived with the test that proves it.

Fix at render

Stored text stays as people typed it. Encoding on output covers every row already in the database, which an input filter never would.

Remove a secret rather than rotate it

The captcha and the mail password went with the features that needed them, so there was nothing left to rotate.

Move the apps, not the data

The databases stayed on the client's own servers, where their local systems already used them. Only the web tier moved to Azure, and its database traffic goes home through encrypted WireGuard tunnels, off the open internet.

Inert until the switch

The single sign-on code was live but switched off for nine days. The cut-over was a change of setting, not a code deploy.

Outcome

Both apps run as containers in Azure, deployed by pipeline with every secret in Key Vault and talking to the client's own database servers through WireGuard tunnels that keep the traffic off the open internet. Since October the identity provider has been the only way to sign in to the client's web estate, including a new staff operations app built alongside it.