Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[24.05] backport fcgiwrap instances fix for local privilege escalation issue #331465

Merged
1 change: 1 addition & 0 deletions nixos/modules/module-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -1456,6 +1456,7 @@
./services/web-servers/caddy/default.nix
./services/web-servers/darkhttpd.nix
./services/web-servers/fcgiwrap.nix
./services/web-servers/fcgiwrap-instances.nix
./services/web-servers/garage.nix
./services/web-servers/hitch/default.nix
./services/web-servers/hydron.nix
Expand Down
15 changes: 7 additions & 8 deletions nixos/modules/services/misc/zoneminder.nix
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,11 @@ in {
];

services = {
fcgiwrap = lib.mkIf useNginx {
enable = true;
preforkProcesses = cfg.cameras;
inherit user group;
fcgiwrap.instances.zoneminder = lib.mkIf useNginx {
process.prefork = cfg.cameras;
process.user = user;
process.group = group;
socket = { inherit (config.services.nginx) user group; };
};

mysql = lib.mkIf cfg.database.createLocally {
Expand All @@ -225,9 +226,7 @@ in {
default = true;
root = "${pkg}/share/zoneminder/www";
listen = [ { addr = "0.0.0.0"; inherit (cfg) port; } ];
extraConfig = let
fcgi = config.services.fcgiwrap;
in ''
extraConfig = ''
index index.php;

location / {
Expand Down Expand Up @@ -257,7 +256,7 @@ in {
fastcgi_param HTTP_PROXY "";
fastcgi_intercept_errors on;

fastcgi_pass ${fcgi.socketType}:${fcgi.socketAddress};
fastcgi_pass unix:${config.services.fcgiwrap.instances.zoneminder.socket.address};
}

location /cache/ {
Expand Down
103 changes: 75 additions & 28 deletions nixos/modules/services/networking/cgit.nix
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ let

regexLocation = cfg: regexEscape (stripLocation cfg);

mkFastcgiPass = cfg: ''
mkFastcgiPass = name: cfg: ''
${if cfg.nginx.location == "/" then ''
fastcgi_param PATH_INFO $uri;
'' else ''
fastcgi_split_path_info ^(${regexLocation cfg})(/.+)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
''
}fastcgi_pass unix:${config.services.fcgiwrap.socketAddress};
}fastcgi_pass unix:${config.services.fcgiwrap.instances."cgit-${name}".socket.address};
'';

cgitrcLine = name: value: "${name}=${
Expand Down Expand Up @@ -72,25 +72,11 @@ let
${cfg.extraConfig}
'';

mkCgitReposDir = cfg:
if cfg.scanPath != null then
cfg.scanPath
else
pkgs.runCommand "cgit-repos" {
preferLocalBuild = true;
allowSubstitutes = false;
} ''
mkdir -p "$out"
${
concatStrings (
mapAttrsToList
(name: value: ''
ln -s ${escapeShellArg value.path} "$out"/${escapeShellArg name}
'')
cfg.repos
)
}
'';
fcgiwrapUnitName = name: "fcgiwrap-cgit-${name}";
fcgiwrapRuntimeDir = name: "/run/${fcgiwrapUnitName name}";
gitProjectRoot = name: cfg: if cfg.scanPath != null
then cfg.scanPath
else "${fcgiwrapRuntimeDir name}/repos";

in
{
Expand Down Expand Up @@ -154,6 +140,30 @@ in
type = types.lines;
default = "";
};

user = mkOption {
description = ''
User to run the cgit service as.

Defaults to "root" for compatibility with legacy setups.
Will default to the unprivileged user "cgit" in NixOS 24.11.
'';
type = types.str;
default = "root";
example = "cgit";
};

group = mkOption {
description = ''
Group to run the cgit service as.

Defaults to "root" for compatibility with legacy setups.
Will default to the unprivileged user "cgit" in NixOS 24.11.
'';
type = types.str;
default = "root";
example = "cgit";
};
};
}));
};
Expand All @@ -165,29 +175,66 @@ in
message = "Exactly one of services.cgit.${vhost}.scanPath or services.cgit.${vhost}.repos must be set.";
}) cfgs;

services.fcgiwrap.enable = true;
warnings = flatten (flip mapAttrsToList cfgs (inst: cfg:
optional (cfg.user == "root") ''
`services.cgit.${inst}` is configured to run as root.
This has security implications: <TODO: advisory link>.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure whether it's usual to post an advisory link in evaluation warnings, but it seems useful to me.

Nevertheless, here's a draft for the advisory:
https://gist.github.com/pacien/82119f8e1a6569c5b12bf0f75ecc3b9a
I'd appreciate a review for it as well.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for your work on this. I think linking to the advisory is great; we should certainly post it on Discourse and arguably https://github.com/NixOS/nixpkgs/security/advisories (as‐yet unused).

I personally feel that a warning is not sufficient and that insecure configurations should be a hard error, because otherwise it’s likely they won’t be surfaced through automatic update pipelines etc.; however I realize there is a compatibility cost here.

Copy link
Member

Choose a reason for hiding this comment

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

I personally feel that a warning is not sufficient and that insecure configurations should be a hard error, because otherwise it’s likely they won’t be surfaced through automatic update pipelines etc.; however I realize there is a compatibility cost here.

I think the biggest issue with that, especially on the stable channel, is that for people using the auto-upgrade and not really monitoring their machine, it would block other compatible security upgrades. Like for example the recent curl CVE.

Copy link
Member

Choose a reason for hiding this comment

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

There’s lots of circumstances where that would bite you. Packages do break unexpectedly. You need monitoring of failures to have security; Nix warnings, on the other hand, are rarely security‐relevant, and are trickier to automatically detect and monitor.

Copy link
Member

Choose a reason for hiding this comment

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

There’s lots of circumstances where that would bite you. Packages do break unexpectedly. You need monitoring of failures to have security; Nix warnings, on the other hand, are rarely security‐relevant, and are trickier to automatically detect and monitor.

I agree, you definitely should monitor your machines. But I think lots of people still don't.

But it's true that warnings would be harder to detect, hmm…

Copy link
Member

Choose a reason for hiding this comment

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

I think we could just add a services.fcgiwrap.allowLocalPrivilegeEscalationUntilNextStableRelease option or something if we want an escape hatch.

I agree that the systemd service failure option is not ideal.

The GitHub advisory tab lets us assign CVEs, as I understand it, so it would actually be quite nice to use. But only repository admins can post stuff; we’d probably need to organize procedures and someone to be responsible for it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should we use the Github advisory then?
Would it be possible to reserve a number and URL to put it in the warnings and errors?

Copy link
Contributor

Choose a reason for hiding this comment

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

GHA are not really adequate in our situation with the current way the permissions are handled. Creation and publication needs to be handled by repo admins and cannot be delegated :/ (also not sure if tying more things with the GH ecosystem is a good play here).

In this specific case at this point the cat is out of the bag so I suggest to go ahead and publish the advisory on https://discourse.nixos.org/c/announcements/security/56 when this is ready to be merged to get the final link so it can be added to the messages.

Copy link
Member

Choose a reason for hiding this comment

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

I think Discourse will be okay, but there’s indeed a bit of a race condition there. Maybe we can post something set to private and then let it go public once we merge? I’m not sure who has that kind of access on Discourse other than the moderation team.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have added a commit which causes an evaluation failure, with a new option
services.fcgiwrap.allowGlobalInstanceLocalPrivilegeEscalation as an escape
hatch.

I'm not sure of the consensus of whether to have this or not, so feel free
to include or not this latest commit.

The error should be clear enough nevertheless.


Regarding the Discourse advisory: making a private post and then making it
public might break notifications.

I'll make the post and update the link in the PR whenever someone will tell me
to.

It is recommended to set an unprivileged user explicitly.
This default user will be set to "cgit" in NixOS 24.11.
''
));

users = mkMerge (flip mapAttrsToList cfgs (_: cfg: {
users.${cfg.user} = {
isSystemUser = true;
inherit (cfg) group;
};
groups.${cfg.group} = { };
}));

services.fcgiwrap.instances = flip mapAttrs' cfgs (name: cfg:
nameValuePair "cgit-${name}" {
process = { inherit (cfg) user group; };
socket = { inherit (config.services.nginx) user group; };
}
);

systemd.services = flip mapAttrs' cfgs (name: cfg:
nameValuePair (fcgiwrapUnitName name)
(mkIf (cfg.repos != { }) {
serviceConfig.RuntimeDirectory = fcgiwrapUnitName name;
preStart = ''
GIT_PROJECT_ROOT=${escapeShellArg (gitProjectRoot name cfg)}
mkdir -p "$GIT_PROJECT_ROOT"
cd "$GIT_PROJECT_ROOT"
${concatLines (flip mapAttrsToList cfg.repos (name: repo: ''
ln -s ${escapeShellArg repo.path} ${escapeShellArg name}
''))}
'';
}
));

services.nginx.enable = true;

services.nginx.virtualHosts = mkMerge (mapAttrsToList (_: cfg: {
services.nginx.virtualHosts = mkMerge (mapAttrsToList (name: cfg: {
${cfg.nginx.virtualHost} = {
locations = (
genAttrs'
[ "cgit.css" "cgit.png" "favicon.ico" "robots.txt" ]
(name: nameValuePair "= ${stripLocation cfg}/${name}" {
(fileName: nameValuePair "= ${stripLocation cfg}/${fileName}" {
extraConfig = ''
alias ${cfg.package}/cgit/${name};
alias ${cfg.package}/cgit/${fileName};
'';
})
) // {
"~ ${regexLocation cfg}/.+/(info/refs|git-upload-pack)" = {
fastcgiParams = rec {
SCRIPT_FILENAME = "${pkgs.git}/libexec/git-core/git-http-backend";
GIT_HTTP_EXPORT_ALL = "1";
GIT_PROJECT_ROOT = mkCgitReposDir cfg;
GIT_PROJECT_ROOT = gitProjectRoot name cfg;
HOME = GIT_PROJECT_ROOT;
};
extraConfig = mkFastcgiPass cfg;
extraConfig = mkFastcgiPass name cfg;
};
"${stripLocation cfg}/" = {
fastcgiParams = {
Expand All @@ -196,7 +243,7 @@ in
HTTP_HOST = "$server_name";
CGIT_CONFIG = mkCgitrc cfg;
};
extraConfig = mkFastcgiPass cfg;
extraConfig = mkFastcgiPass name cfg;
};
};
};
Expand Down
9 changes: 7 additions & 2 deletions nixos/modules/services/networking/smokeping.nix
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ in
};
preStart = ''
mkdir -m 0755 -p ${smokepingHome}/cache ${smokepingHome}/data
chown -R ${cfg.user}:${cfg.user} ${smokepingHome}/{cache,data}
bendlas marked this conversation as resolved.
Show resolved Hide resolved
ln -snf ${cfg.package}/htdocs/css ${smokepingHome}/css
ln -snf ${cfg.package}/htdocs/js ${smokepingHome}/js
ln -snf ${cgiHome} ${smokepingHome}/smokeping.fcgi
Expand All @@ -337,7 +338,11 @@ in
};

# use nginx to serve the smokeping web service
services.fcgiwrap.enable = mkIf cfg.webService true;
services.fcgiwrap.instances.smokeping = mkIf cfg.webService {
process.user = cfg.user;
process.group = cfg.user;
bendlas marked this conversation as resolved.
Show resolved Hide resolved
socket = { inherit (config.services.nginx) user group; };
};
services.nginx = mkIf cfg.webService {
enable = true;
virtualHosts."smokeping" = {
Expand All @@ -349,7 +354,7 @@ in
locations."/smokeping.fcgi" = {
extraConfig = ''
include ${config.services.nginx.package}/conf/fastcgi_params;
fastcgi_pass unix:${config.services.fcgiwrap.socketAddress};
fastcgi_pass unix:${config.services.fcgiwrap.instances.smokeping.socket.address};
fastcgi_param SCRIPT_FILENAME ${smokepingHome}/smokeping.fcgi;
fastcgi_param DOCUMENT_ROOT ${smokepingHome};
'';
Expand Down
136 changes: 136 additions & 0 deletions nixos/modules/services/web-servers/fcgiwrap-instances.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
{ config, lib, pkgs, ... }:

with lib;

let
forEachInstance = f: flip mapAttrs' config.services.fcgiwrap.instances (
name: cfg: nameValuePair "fcgiwrap-${name}" (f cfg)
);

in {
options.services.fcgiwrap.instances = mkOption {
description = "Configuration for fcgiwrap instances.";
default = { };
type = types.attrsOf (types.submodule ({ config, ... }: { options = {
process.prefork = mkOption {
type = types.ints.positive;
default = 1;
description = "Number of processes to prefork.";
};

process.user = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
User as which this instance of fcgiwrap will be run.
Set to `null` (the default) to use a dynamically allocated user.
'';
};

process.group = mkOption {
type = types.nullOr types.str;
default = null;
description = "Group as which this instance of fcgiwrap will be run.";
};

socket.type = mkOption {
type = types.enum [ "unix" "tcp" "tcp6" ];
default = "unix";
description = "Socket type: 'unix', 'tcp' or 'tcp6'.";
};

socket.address = mkOption {
type = types.str;
default = "/run/fcgiwrap-${config._module.args.name}.sock";
example = "1.2.3.4:5678";
description = ''
Socket address.
In case of a UNIX socket, this should be its filesystem path.
'';
};

socket.user = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
User to be set as owner of the UNIX socket.
'';
};

socket.group = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Group to be set as owner of the UNIX socket.
'';
};

socket.mode = mkOption {
type = types.nullOr types.str;
default = if config.socket.type == "unix" then "0600" else null;
defaultText = literalExpression ''
if config.socket.type == "unix" then "0600" else null
'';
description = ''
Mode to be set on the UNIX socket.
Defaults to private to the socket's owner.
'';
};
}; }));
};

config = {
assertions = concatLists (mapAttrsToList (name: cfg: [
{
assertion = cfg.socket.type == "unix" -> cfg.socket.user != null;
message = "Socket owner is required for the UNIX socket type.";
}
{
assertion = cfg.socket.type == "unix" -> cfg.socket.group != null;
message = "Socket owner is required for the UNIX socket type.";
}
{
assertion = cfg.socket.user != null -> cfg.socket.type == "unix";
message = "Socket owner can only be set for the UNIX socket type.";
}
{
assertion = cfg.socket.group != null -> cfg.socket.type == "unix";
message = "Socket owner can only be set for the UNIX socket type.";
}
{
assertion = cfg.socket.mode != null -> cfg.socket.type == "unix";
message = "Socket mode can only be set for the UNIX socket type.";
}
]) config.services.fcgiwrap.instances);

systemd.services = forEachInstance (cfg: {
after = [ "nss-user-lookup.target" ];
wantedBy = optional (cfg.socket.type != "unix") "multi-user.target";

serviceConfig = {
ExecStart = ''
${pkgs.fcgiwrap}/sbin/fcgiwrap ${cli.toGNUCommandLineShell {} ({
c = cfg.process.prefork;
} // (optionalAttrs (cfg.socket.type != "unix") {
s = "${cfg.socket.type}:${cfg.socket.address}";
}))}
'';
} // (if cfg.process.user != null then {
User = cfg.process.user;
Group = cfg.process.group;
} else {
DynamicUser = true;
});
});

systemd.sockets = forEachInstance (cfg: mkIf (cfg.socket.type == "unix") {
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = cfg.socket.address;
SocketUser = cfg.socket.user;
SocketGroup = cfg.socket.group;
SocketMode = cfg.socket.mode;
};
});
};
}
Loading