Harden systemd services, restrict file permissions further, add username blocklist and prevent more vulnerabilities

This commit is contained in:
thiloho
2024-12-08 14:33:33 +01:00
parent 46b8cb033c
commit 18210d501b
8 changed files with 73 additions and 14 deletions

View File

@@ -9,6 +9,29 @@ with lib;
let let
cfg = config.services.archtika; cfg = config.services.archtika;
baseHardenedSystemdOptions = {
CapabilityBoundingSet = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RemoveIPC = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = ["@system-service" "~@privileged" "~@resources"];
ReadWritePaths = ["/var/www/archtika-websites"];
};
in in
{ {
options.services.archtika = { options.services.archtika = {
@@ -105,9 +128,17 @@ in
group = cfg.group; group = cfg.group;
}; };
users.groups.${cfg.group} = { }; users.groups.${cfg.group} = {
members = [
"nginx"
"postgres"
];
};
systemd.tmpfiles.rules = [ "d /var/www/archtika-websites 0777 ${cfg.user} ${cfg.group} -" ]; systemd.tmpfiles.rules = [
"d /var/www 0755 root root -"
"d /var/www/archtika-websites 0770 ${cfg.user} ${cfg.group} -"
];
systemd.services.archtika-api = { systemd.services.archtika-api = {
description = "archtika API service"; description = "archtika API service";
@@ -117,11 +148,13 @@ in
"postgresql.service" "postgresql.service"
]; ];
serviceConfig = { serviceConfig = baseHardenedSystemdOptions // {
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
Restart = "always"; Restart = "always";
WorkingDirectory = "${cfg.package}/rest-api"; WorkingDirectory = "${cfg.package}/rest-api";
RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_UNIX"];
}; };
script = '' script = ''
@@ -142,11 +175,13 @@ in
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]; after = [ "network.target" ];
serviceConfig = { serviceConfig = baseHardenedSystemdOptions // {
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
Restart = "always"; Restart = "always";
WorkingDirectory = "${cfg.package}/web-app"; WorkingDirectory = "${cfg.package}/web-app";
RestrictAddressFamilies = ["AF_INET" "AF_INET6"];
}; };
script = '' script = ''

View File

@@ -32,10 +32,15 @@ ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
CREATE FUNCTION internal.generate_slug (TEXT) CREATE FUNCTION internal.generate_slug (TEXT)
RETURNS TEXT RETURNS TEXT
AS $$ AS $$
SELECT BEGIN
REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(LOWER(TRIM(REGEXP_REPLACE(unaccent ($1), '\s+', '-', 'g'))), '[^\w-]', '', 'g'), '-+', '-', 'g'), '^-+', '', 'g'), '-+$', '', 'g') IF $1 ~ '[/\\.]' THEN
RAISE invalid_parameter_value
USING message = 'Title cannot contain "/", "\" or "."';
END IF;
RETURN REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(LOWER(TRIM(REGEXP_REPLACE(unaccent ($1), '\s+', '-', 'g'))), '[^\w-]', '', 'g'), '-+', '-', 'g'), '^-+', '', 'g'), '-+$', '', 'g');
END;
$$ $$
LANGUAGE sql LANGUAGE plpgsql
IMMUTABLE; IMMUTABLE;
GRANT EXECUTE ON FUNCTION internal.generate_slug TO authenticated_user; GRANT EXECUTE ON FUNCTION internal.generate_slug TO authenticated_user;

View File

@@ -120,7 +120,7 @@ AS $$
DECLARE DECLARE
_role NAME; _role NAME;
_user_id UUID; _user_id UUID;
_exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 86400; _exp INT := EXTRACT(EPOCH FROM CLOCK_TIMESTAMP())::INT + 43200;
BEGIN BEGIN
SELECT SELECT
internal.user_role (login.username, login.pass) INTO _role; internal.user_role (login.username, login.pass) INTO _role;

View File

@@ -46,12 +46,12 @@ LANGUAGE plpgsql
SECURITY DEFINER; SECURITY DEFINER;
CREATE TRIGGER _cleanup_filesystem_website CREATE TRIGGER _cleanup_filesystem_website
BEFORE UPDATE OR DELETE ON internal.website BEFORE UPDATE OF title OR DELETE ON internal.website
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem (); EXECUTE FUNCTION internal.cleanup_filesystem ();
CREATE TRIGGER _cleanup_filesystem_article CREATE TRIGGER _cleanup_filesystem_article
BEFORE UPDATE OR DELETE ON internal.article BEFORE UPDATE OF title OR DELETE ON internal.article
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION internal.cleanup_filesystem (); EXECUTE FUNCTION internal.cleanup_filesystem ();

View File

@@ -0,0 +1,8 @@
-- migrate:up
ALTER TABLE internal.user
ADD CONSTRAINT username_not_blocked CHECK (LOWER(username) NOT IN ('admin', 'administrator', 'api', 'auth', 'blog', 'cdn', 'docs', 'help', 'login', 'logout', 'profile', 'register', 'settings', 'setup', 'signin', 'signup', 'support', 'test', 'www'));
-- migrate:down
ALTER TABLE internal.user
DROP CONSTRAINT username_not_blocked;

View File

@@ -26,6 +26,8 @@
}); });
const tabs = ["settings", "articles", "categories", "collaborators", "publish", "logs"]; const tabs = ["settings", "articles", "categories", "collaborators", "publish", "logs"];
let iframeLoaded = $state(false);
</script> </script>
<input type="checkbox" id="toggle-mobile-preview" hidden /> <input type="checkbox" id="toggle-mobile-preview" hidden />
@@ -55,7 +57,15 @@
<div class="preview" bind:this={previewElement}> <div class="preview" bind:this={previewElement}>
{#if fullPreview} {#if fullPreview}
<iframe src={previewContent.value} title="Preview"></iframe> {#if !iframeLoaded}
<p>Loading preview...</p>
{/if}
<iframe
src={previewContent.value}
title="Preview"
onload={() => (iframeLoaded = true)}
style:display={iframeLoaded ? "block" : "none"}
></iframe>
{:else} {:else}
{@html md( {@html md(
previewContent.value || "Write some markdown content to see a live preview here", previewContent.value || "Write some markdown content to see a live preview here",

View File

@@ -18,7 +18,7 @@ export const actions: Actions = {
return response; return response;
} }
cookies.set("session_token", response.data.token, { path: "/", maxAge: 86400 }); cookies.set("session_token", response.data.token, { path: "/", maxAge: 43200 });
return response; return response;
} }
}; };

View File

@@ -269,14 +269,15 @@ const generateStaticFiles = async (
}; };
const setPermissions = async (dir: string) => { const setPermissions = async (dir: string) => {
await chmod(dir, 0o777); const mode = dev ? 0o777 : process.env.ORIGIN ? 0o770 : 0o777;
await chmod(dir, mode);
const entries = await readdir(dir, { withFileTypes: true }); const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry.name); const fullPath = join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
await setPermissions(fullPath); await setPermissions(fullPath);
} else { } else {
await chmod(fullPath, 0o777); await chmod(fullPath, mode);
} }
} }
}; };