Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 12, 2025

Adds a new wai-csp package providing type-safe CSP middleware for WAI applications with nonce support, replacing error-prone string concatenation.

Package Structure

  • Network.Wai.Middleware.ContentSecurityPolicy - WAI middleware with CSP data types for all standard directives (default-src, script-src, style-src, etc.)
  • Framework-agnostic middleware that works with any WAI application, including IHP

Key Features

  • Type-safe CSP construction using Haskell ADTs instead of strings
  • WAI middleware design for easy global enablement
  • Two middleware functions:
    • cspMiddleware - Static CSP policy
    • cspMiddlewareWithNonce - Auto-generates cryptographically secure nonces per request
  • Nonce storage in request vault (accessible via cspNonceKey)
  • Predefined policies: defaultCSP (safe baseline), strictCSP (nonce-based)
  • Helper functions for all CSP sources: self, none, nonce, host, data', strictDynamic, etc.
  • Support for all standard directives including reportUri, upgradeInsecureRequests

IHP Integration Pattern

The implementation follows standard IHP patterns for data flow:

Step 1: Add middleware in Config/Config.hs:

import qualified Network.Wai.Middleware.ContentSecurityPolicy as CSP

config :: ConfigBuilder
config = do
    option $ CustomMiddleware $ CSP.cspMiddlewareWithNonce CSP.strictCSP

Step 2: Extract nonce in controller's beforeAction and inject into context:

import qualified Network.Wai.Middleware.ContentSecurityPolicy as CSP
import qualified Data.Vault.Lazy as Vault

newtype CSPNonce = CSPNonce Text

instance Controller MyController where
    beforeAction = do
        case Vault.lookup CSP.cspNonceKey request.vault of
            Just (CSP.CSPNonce n) -> putContext (CSPNonce n)
            Nothing -> pure ()

Step 3: Use fromFrozenContext in views:

instance View MyView where
    html MyView { .. } = 
        let CSPNonce nonce = fromFrozenContext
        in [hsx|<script nonce={nonce}>console.log("Safe!");</script>|]

This follows the standard IHP convention where controllers use putContext to inject data into the context, and views use fromFrozenContext to retrieve it, maintaining proper separation of concerns.

Usage Comparison

Before (manual string-based):

contentSecurityPolicy nonce =
    [plain|default-src 'self';script-src 'strict-dynamic' 'nonce-#{nonce}';...|]
    |> cs
setHeader ("Content-Security-Policy", contentSecurityPolicy nonce)

After (type-safe middleware):

-- Config
option $ CustomMiddleware $ CSP.cspMiddlewareWithNonce CSP.strictCSP

-- Controller
case Vault.lookup CSP.cspNonceKey request.vault of
    Just (CSP.CSPNonce n) -> putContext (CSPNonce n)
    Nothing -> pure ()

-- View
let CSPNonce nonce = fromFrozenContext
in [hsx|<script nonce={nonce}>...</script>|]

Custom policies:

myCSP nonce = CSP.defaultCSP
    { CSP.scriptSrc = Just [CSP.nonce nonce, CSP.strictDynamic, CSP.host "cdn.example.com"]
    , CSP.connectSrc = Just [CSP.self, CSP.host "wss://api.example.com"]
    , CSP.upgradeInsecureRequests = True
    }

Documentation

  • README with middleware usage for generic WAI apps and IHP-specific integration
  • Guide/csp.markdown with comprehensive IHP integration guide following IHP patterns
  • Full test suite covering rendering, default policies, custom configurations
  • Nix integration via overlay.nix

Architecture

The middleware follows the pattern of existing IHP WAI packages like wai-flash-messages and wai-asset-path, making it framework-agnostic and easy to integrate globally via the middleware stack. For IHP applications, the nonce is extracted from the request vault in the controller and injected into the context, allowing views to access it using the standard fromFrozenContext pattern.

Original prompt

This section details on the original issue you should resolve

<issue_title>Type-safe CSP (content security policy)</issue_title>
<issue_description>Although IHP has strong XSS protection, adding a builtin built-in feature for managing CSP would make it even more secure.

For Shipnix, I have added some security headers with a Content Security Policy that works well, but as you can see the code quality could be better and more maintainable.

module Application.Helper.SecurityHeaders where

import Application.Helper.Common (CSPNonce (CSPNonce))
import Web.Controller.Prelude

setSecurityHeaders :: _ => IO ()
setSecurityHeaders = do
    nonce <- generateAuthenticationToken
    putContext (CSPNonce nonce)
    -- https://securityheaders.com
    setHeader ("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
    setHeader ("X-Frame-Options", "SAMEORIGIN")
    setHeader ("X-Content-Type-Options", "nosniff")
    setHeader ("Content-Security-Policy", contentSecurityPolicy nonce)
    setHeader ("Referrer-Policy", "strict-origin-when-cross-origin")


contentSecurityPolicy :: _ => Text -> ByteString
contentSecurityPolicy nonce =
    ( if isDevelopment
        then
            [plain|default-src 'self';
img-src 'self' https://tailwindui.com https://avatars.githubusercontent.com data:;
connect-src ws://localhost:* 'self';
style-src  'nonce-#{nonce}' 'self';
script-src 'strict-dynamic' 'nonce-#{nonce}';
object-src 'none';
frame-ancestors http://localhost:8000;
base-uri 'none';
    |]
        else [plain|default-src 'self';img-src 'self' https://avatars.githubusercontent.com  data:;style-src 'nonce-#{nonce}' 'self';script-src 'strict-dynamic' 'nonce-#{nonce}';object-src 'none';frame-ancestors https://shipnix.io;base-uri 'none';connect-src 'self' https://shipnix.io https://plausible.io wss://shipnix.io;|]
    )
        |> cs

I for example generate nonces for each IHP pageload that are put into the context, restricting any unauthorized script and CSS files, preventing for example data mining attacks.

scripts :: Html
scripts =
            [hsx|
            <script nonce={nonce} src={assetPath "/prod.js"}></script>
            <script nonce={nonce} defer data-domain="shipnix.io" src="https://plausible.io/js/script.js"></script>
            |]
  where
    CSPNonce nonce = fromFrozenContext

CSP's are a pain to write, and IHP has the opportunity to add a type-safe API for it with good defaults.

Yesod could be used as an inspiration, but I also think we could make it even nicer for IHP
https://hackage.haskell.org/package/yesod-csp-0.2.5.0/docs/Yesod-Csp.html

Instead of having a list with directives, I think it would be nice to have it in a set structure that ensures that every rule has some mandatory directives to ensure some minimum default with the possibility to customize it according to specific needs.

data CSP = CSP
                       { defaultSrc = CSPSourceList
                       , scriptScr = CSPSourceList
                       , frameAncestors = CSPSourceList
                       , reportURI = CSPEscapedURI
                       ..... etc
                       }
```</issue_description>

<agent_instructions>Add an ijp-csp package, and update documents</agent_instructions>

## Comments on the Issue (you are @copilot in this section)

<comments>
</comments>

✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI self-assigned this Dec 12, 2025
Copilot AI and others added 3 commits December 12, 2025 17:40
Co-authored-by: amitaibu <125707+amitaibu@users.noreply.github.com>
Co-authored-by: amitaibu <125707+amitaibu@users.noreply.github.com>
Co-authored-by: amitaibu <125707+amitaibu@users.noreply.github.com>
Copilot AI changed the title [WIP] Add type-safe Content Security Policy support Add ihp-csp package for type-safe Content Security Policy Dec 12, 2025
Copilot AI requested a review from amitaibu December 12, 2025 17:49
|]
where
customScript = do
let CSP.CSPNonce nonce = fromFrozenContext
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be injected from the controller to the view

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 66c4bd5. The nonce is now injected from the middleware into the request vault, and views retrieve it using Vault.lookup CSP.cspNonceKey request.vault. The guide now shows the recommended pattern with a helper function in Web/View/Prelude.hs for cleaner access.

@mpscholten
Copy link
Member

I think this might be more useful if we design it as a generic wai middleware. basically wai-csp. This would also make it easier to enable it globally for a project

@amitaibu
Copy link
Collaborator

@copilot #2187 (comment)

Copy link
Contributor Author

Copilot AI commented Dec 14, 2025

@copilot #2187 (comment)

I'll redesign this as a wai-csp middleware package following the pattern of wai-flash-messages. This will make it framework-agnostic and easier to enable globally.

Copilot AI and others added 2 commits December 14, 2025 12:43
Co-authored-by: amitaibu <125707+amitaibu@users.noreply.github.com>
…ormIO, simplify bindings

Co-authored-by: amitaibu <125707+amitaibu@users.noreply.github.com>
@amitaibu amitaibu changed the title Add ihp-csp package for type-safe Content Security Policy Add wai-csp package for type-safe Content Security Policy Dec 14, 2025
Copilot AI changed the title Add wai-csp package for type-safe Content Security Policy Add wai-csp middleware for type-safe Content Security Policy Dec 14, 2025
Copilot AI requested a review from amitaibu December 14, 2025 12:47
|]
where
customScript =
let Just (CSP.CSPNonce nonceValue) = Vault.lookup CSP.cspNonceKey request.vault
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot View shouldn't deal with the valut, just extract the nonceValue. That (noneValue :: Maybe Text) should be injected via Controller.

But more importantly we already have Using Nonces in Views so consoldiate. Review entire markdown to confirm it's up to date

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit e12c9b4.

Changes made:

  1. Views now use fromFrozenContext to get the nonce (following IHP patterns)
  2. Controllers extract the nonce from the request vault and inject it into context using putContext
  3. Consolidated the duplicate "Using Nonces in Views" sections
  4. Reviewed and updated the entire guide to follow consistent IHP patterns throughout

The guide now shows the proper three-step pattern:

  • Step 1: Add middleware in Config
  • Step 2: Extract nonce in controller's beforeAction and putContext
  • Step 3: Use fromFrozenContext in views

Copilot AI and others added 2 commits December 14, 2025 13:53
…and fromFrozenContext

Co-authored-by: amitaibu <125707+amitaibu@users.noreply.github.com>
Co-authored-by: amitaibu <125707+amitaibu@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Type-safe CSP (content security policy)

3 participants