Skip to content

[PL-133773] Add initial ModSecurity implementation#2196

Open
nichmoe wants to merge 1 commit intofc-25.11-devfrom
PL-133773-Add-ModSecurity-configuration-to-Nginx-service
Open

[PL-133773] Add initial ModSecurity implementation#2196
nichmoe wants to merge 1 commit intofc-25.11-devfrom
PL-133773-Add-ModSecurity-configuration-to-Nginx-service

Conversation

@nichmoe
Copy link
Contributor

@nichmoe nichmoe commented Feb 24, 2026

@flyingcircusio/release-managers

PR release workflow (internal)

  • PR has internal ticket
  • internal issue ID (PL-…) part of branch name
  • internal issue ID mentioned in PR description text
  • ticket is on Platform agile board
  • ensure all checks are green
  • get a review from a colleague

Design notes

  • Provide a feature toggle if the change might need to be adjusted/reverted quickly depending on context. Consider whether the default should be on or off. Example: rate limiting.
  • Provide warnings in previous platform version and upgrade notes when introducing a breaking change --> no breaking changes

Security implications

  • Security requirements defined? (WHERE)
    No specific requirements
  • Security requirements tested? (EVIDENCE)
    Manual tests on dev server

This PR adds a minimal initial integration of ModSecurity. It is intended to be properly documented and supported with tests, after a bit of practical testing and further development.

See PL-133773

@nichmoe nichmoe added enhancement New feature or request risk: 2 low risk urgency: 2 low urgency labels Feb 24, 2026
@nichmoe nichmoe requested review from ctheune and osnyx and removed request for ctheune February 24, 2026 08:36
@nichmoe nichmoe marked this pull request as ready for review February 24, 2026 08:39
};

config = {
configContent = mkModsecurityConfig config;
Copy link
Member

Choose a reason for hiding this comment

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

Ambiguous naming, which config is this supposed to refer to? My intuition would be cfg, but you provide config which refers to the whole top-level NixOS system config?

Copy link
Member

Choose a reason for hiding this comment

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

It is probably a better idea to simplify the option definitions into one set of options, instead of deferring to multiple submodules, and then puzzel this together in the single config block with mkIf conditionals.

Copy link
Member

Choose a reason for hiding this comment

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

This config attribute name refers to the top-level config of this submodule scope. Let's see if this could be renamed or has to be this way.
The general idea here is that the rulesetImplModule generates a modsecurity config that is standalone in a way that it could be used in several places like nginx, apache, or other supporting software. This is then expose dthrough the configContent option.

According to @dpausp, this is built this way to avoid infinite recursions encoutered when building otherwise. (together with lazyAttrs in line 96).

};

services.nginx.virtualHosts = mkOption {
type = types.attrsOf (types.submodule nginxIntegrationSubmodule);
Copy link
Member

Choose a reason for hiding this comment

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

What is the reason of deferring this to an own submodule? At least now, the nginxIntegrationSubmodule does not even provide an option interface on its own, so it would be straightforward to just inline the code here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sensible point, will be set inline.

options = with lib; {
flyingcircus.modsecurity = {

enable = mkEnableOption { };
Copy link
Member

Choose a reason for hiding this comment

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

AFAIK this is the wrong type signature, usually an option description goes here. Did this ever evaluate successfully?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It did evaluate, but it will most likely blow up, when something tries to get the description. This needs fixing.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, thhis is the wrong type signature, but currently only causes the description to fail to evaluate.

inherit lib pkgs rules;
};

rulesetOptionsModule = {
Copy link
Member

Choose a reason for hiding this comment

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

Why does the submodule definition have its own file, do you plan to re-use it somewhere else?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can be moved into the rulesetImplModule, or a renamed equivalent.


mkModsecurityConfig =
rules:
(import ./mk-modsecurity-config.nix) {
Copy link
Member

Choose a reason for hiding this comment

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

Why does this function definition have its own file, do you plan to re-use it somewhere else?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This isn't reused but could be in the future, see ModSecurity for Apache.

@@ -0,0 +1,92 @@
{ lib, ... }:

with lib;
Copy link
Member

Choose a reason for hiding this comment

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

top-level with declarations are strongly discouraged against.
I know when have plenty of those in our code, but for new modules I prefer starting with a cleaner slate.

};
};

services.nginx.virtualHosts = mkOption {
Copy link
Member

Choose a reason for hiding this comment

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

This conflicts with the approach taken in

# Inject our custom access/error logging to every vHosts' extraConfig
options.services.nginx.virtualHosts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ name, config, ... }:
let
servername = if config.serverName != null then config.serverName else name;
in
{
options = {
extraConfig = lib.mkOption {
apply =
orig:
orig
+ lib.optionalString (cfg.logPerVirtualHost) ''
access_log /var/log/nginx/access-${servername}.log;
error_log /var/log/nginx/error-${servername}.log;
'';
};
};
}
)
);
};
.

Copy link
Member

Choose a reason for hiding this comment

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

The mkBefore approach taken here is probably even preferrable to the apply approach in the nginx module, as it composes better.
Just make sure to unify both sides to take the same approach, I suggest adjusting the code in ngix/default.nix.

...
}:
let
cfg = config.flyingcircus.modsecurity;
Copy link
Member

Choose a reason for hiding this comment

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

I suggest moving this downwards to flyingcircus.services.modsecurity or, as this is currently nginx-specific, even flyingcircus.services.nginx.modsecurity.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moving to flyingcircus.services.modsecurity is the best solutions, as ModSecurity can also be used with Apache.

Copy link
Member

@osnyx osnyx left a comment

Choose a reason for hiding this comment

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

I am not really convinced by several architecture decisions taken here, these are a bit unusual at least.
Additionally, please provide an actual example config using this module so I can make sense of what this is used for in practice. Then we can simplify the module together.

configContent = mkModsecurityConfig config;
rulesFile = pkgs.writeText name config.configContent;
nginxConfig = config.mkNginxConfig config.nginxMode "";
mkNginxConfig =
Copy link
Member

Choose a reason for hiding this comment

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

The idea here was to expose this function as a handle for deployments to re-use, enabling more composability.

Copy link
Member

@osnyx osnyx Mar 5, 2026

Choose a reason for hiding this comment

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

Okay, I think it still makes sense to expose this for re-use in a deployment, but here is my idea:

  • turn this into a pure function: add the ruleset to use as a 3rd argument
  • then expose this as a function, similar to like generators are exposed in the NixOS lib code

Because this is not really an option in the option sense of "it makes use of the NixOS evalModules for achieving merging of option values, checking eligible option values, and keeping state. It's just a convenience function.

Update: At a closer look, it is apparently not that simple to expose plain non-options under the same naming scope as the surrounding options. Then at least mark the option as lib.types.raw.

(lib.optionalString (nginxMode != "Transparent") "modsecurity ${(lib.toSentenceCase nginxMode)}")
"modsecurity_rules_file ${config.rulesFile}"
"modsecurity_transaction_id ${config.transactionID}"
extraConfig
Copy link
Contributor Author

Choose a reason for hiding this comment

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

extraConfig needs a trailing newline.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  flyingcircus.services.nginx.virtualHosts."modsectest.test.fcio.net" = {
    forceSSL = true;
    enableACME = true;
    extraConfig = config.flyingcircus.modsecurity.rulesets."modsectest.test.fcio.net.foo.bar".mkNginxConfig "On" "##foo";
  };

Creates

modsecurity On;
modsecurity_rules_file /nix/store/mxs83kyjnsv38k91cl6q3ynppjlvrph6-modsectest.test.fcio.net.foo.bar;
modsecurity_transaction_id $request_id;
##fooaccess_log /var/log/nginx/access-modsectest.test.fcio.net.log;
error_log /var/log/nginx/error-modsectest.test.fcio.net.log;

@nichmoe nichmoe assigned nichmoe and unassigned osnyx Mar 5, 2026
Copy link
Member

@osnyx osnyx left a comment

Choose a reason for hiding this comment

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

After reviewing this together, here are the suggestions for restructuring this:

  • Split this into 2 main modules, similar to how security.acme defines the certificates that are then used and referenced by several other modules, like in the nginx modules
  • flyingcircus.services.modsecurity module: merge the rulesetOptionsModule and rulesetImplModule. Specifying a ruleset and then rendering it into an actual modsecurity config format are tightly coupled
    • we might even be able to get rid of the submodule-level config in line 62, as this is now a module on its own, not a submodule
  • flyingcircus.(?)services.nginx.virtualHosts receives an option that can then reference these rule sets and configure their particular usage within that nginx host
    • this is then also the place for helpers generating the nginx-specific config embedding

Avoid convoluting submodules unless they are really necessary. The nginxIntegrationSubmodule stops being a submodule and instead configures nginx directly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request risk: 2 low risk urgency: 2 low urgency

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants