From 5d59e9aadab34a8c7c8ea4b0c2d6e3e16cbb5b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dorian=20Le=C5=BCuch?= Date: Thu, 31 Jul 2025 15:23:25 +0200 Subject: [PATCH 1/7] Add Zone Superadmin role with SOA/NS editing capabilities --- migrations/007.php | 4 ++++ model/migrationdirectory.php | 2 +- model/user.php | 9 ++++++++ model/zone.php | 2 +- templates/zone.php | 41 +++++++++++++++++++++--------------- views/zone.php | 12 +++++------ 6 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 migrations/007.php diff --git a/migrations/007.php b/migrations/007.php new file mode 100644 index 0000000..b80d83a --- /dev/null +++ b/migrations/007.php @@ -0,0 +1,4 @@ +database->exec("ALTER TYPE access_level ADD VALUE IF NOT EXISTS 'superadministrator'"); diff --git a/model/migrationdirectory.php b/model/migrationdirectory.php index 4d49451..83de45d 100644 --- a/model/migrationdirectory.php +++ b/model/migrationdirectory.php @@ -22,7 +22,7 @@ class MigrationDirectory extends DBDirectory { /** * Increment this constant to activate a new migration from the migrations directory */ - const LAST_MIGRATION = 6; + const LAST_MIGRATION = 7; public function __construct() { parent::__construct(); diff --git a/model/user.php b/model/user.php index 5427f3c..0cdec08 100644 --- a/model/user.php +++ b/model/user.php @@ -195,6 +195,15 @@ public function access_to(Zone $zone) { } } + /** + * Check if this user is a zone superadministrator for the specified zone. + * @param Zone $zone to check for superadmin access + * @return bool true if user is zone superadministrator + */ + public function is_zone_superadmin(Zone $zone) { + return $this->access_to($zone) === 'superadministrator'; + } + /** * List all zones that this user is an administrator of * @param array $include list of extra data to include in response diff --git a/model/zone.php b/model/zone.php index da23521..5759c08 100644 --- a/model/zone.php +++ b/model/zone.php @@ -781,7 +781,7 @@ private function process_rrset_action($update, &$trash, &$revs_missing, &$revs_u $update->oldname = $update->name; $update->oldtype = $update->type; } - if(($update->type == 'SOA' || $update->type == 'NS') && !$active_user->admin) return; + if(($update->type == 'SOA' || $update->type == 'NS') && !($active_user->admin || $active_user->is_zone_superadmin($this))) return; if(isset($config['dns']['autocreate_reverse_records'])) { $autocreate_ptr = (bool)$config['dns']['autocreate_reverse_records']; diff --git a/templates/zone.php b/templates/zone.php index 09c395e..9973adc 100644 --- a/templates/zone.php +++ b/templates/zone.php @@ -86,7 +86,7 @@ $rrsetnum = 0; foreach($rrsets as $rrset) { if($rrset->type == 'SOA') continue; - if($rrset->type == 'NS' && !$active_user->admin) continue; + if($rrset->type == 'NS' && !($active_user->admin || $active_user->is_zone_superadmin($zone))) continue; $rrsetnum++; $rrs = $rrset->list_resource_records(); $name = DNSName::abbreviate($rrset->name, $zone->name); @@ -164,7 +164,7 @@ - admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?> @@ -180,7 +180,7 @@ - admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?> @@ -219,9 +219,10 @@ -
>
+
> +
- admin || $active_user->access_to($zone) == 'administrator') && !$force_change_review) { ?> + admin || $active_user->access_to($zone) == 'administrator' || $active_user->is_zone_superadmin($zone)) && !$force_change_review) { ?>

@@ -259,7 +260,7 @@
- admin || $active_user->access_to($zone) == 'administrator') { ?> + admin || $active_user->access_to($zone) == 'administrator' || $active_user->is_zone_superadmin($zone)) { ?> @@ -367,7 +368,7 @@
- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?> @@ -405,7 +406,7 @@

Start of authority (SOA)

- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>
@@ -419,7 +420,7 @@
- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>

soa->primary_ns)?>

@@ -429,7 +430,7 @@
- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>

soa->contact)?>

@@ -445,7 +446,7 @@
- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>

soa->refresh))?>

@@ -455,7 +456,7 @@
- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>

soa->retry))?>

@@ -465,7 +466,7 @@
- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>

soa->expiry))?>

@@ -475,7 +476,7 @@
- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>

soa->default_ttl))?>

@@ -485,7 +486,7 @@
- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>

soa->ttl))?>

@@ -493,7 +494,7 @@

- admin) { ?> + admin || $active_user->is_zone_superadmin($zone)) { ?>
@@ -783,6 +784,12 @@ function pagination($page) {
+
+ +
+ From f3ecb59e542cc1a4fc366e65ad75888a6c63cfab Mon Sep 17 00:00:00 2001 From: ictud Date: Tue, 5 Aug 2025 14:27:19 +0200 Subject: [PATCH 3/7] Show NS/CAA records to all users with read-only protection --- public_html/extra.js | 26 ++++++++++++++++++++------ public_html/style.css | 11 +++++++++++ templates/zone.php | 16 +++++++++++----- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/public_html/extra.js b/public_html/extra.js index 6659f6b..79f09bc 100644 --- a/public_html/extra.js +++ b/public_html/extra.js @@ -77,12 +77,12 @@ $(function() { $('#new_ttl').data('default-value', $('#new_ttl').val()); $('option:first-of-type', typeselect).remove(); $('button.delete-rr', form).on('click', function() { delete_rr($(this)); }); - $('td.name', form).on('click', function() { make_editable($(this), 'name'); }); - $('td.type', form).on('click', function() { make_editable($(this), 'type'); }); - $('td.ttl', form).on('click', function() { make_editable($(this), 'ttl'); }); - $('td.content', form).on('click', function() { make_editable($(this), 'content'); }); - $('td.enabled', form).on('click', function() { make_editable($(this)); }); - $('td.comment', form).on('click', function() { make_editable($(this), 'comment'); }); + $('td.name', form).on('click', function() { if(can_edit_record($(this))) make_editable($(this), 'name'); }); + $('td.type', form).on('click', function() { if(can_edit_record($(this))) make_editable($(this), 'type'); }); + $('td.ttl', form).on('click', function() { if(can_edit_record($(this))) make_editable($(this), 'ttl'); }); + $('td.content', form).on('click', function() { if(can_edit_record($(this))) make_editable($(this), 'content'); }); + $('td.enabled', form).on('click', function() { if(can_edit_record($(this))) make_editable($(this)); }); + $('td.comment', form).on('click', function() { if(can_edit_record($(this))) make_editable($(this), 'comment'); }); $('tbody tr', form).each(function() { max_rrsetnum = Math.max(max_rrsetnum, parseInt($(this).data('rrsetnum'), 10)); }); $('#new_name').on('keyup', function(event) { validate_new(); if(event.which == 13) add_new(); }); $('#new_name').on('change', function() { validate_new(); }); @@ -117,6 +117,20 @@ $(function() { update_changed(button); } + // Check if user can edit this record + function can_edit_record(element) { + var tr = element.closest('tr'); + var recordType = tr.data('type'); + var userAdmin = form.data('user-admin') == 1; + var userSuperadmin = form.data('user-superadmin') == 1; + + // NS and CAA records can only be edited by admins or super zone admins + if((recordType == 'NS' || recordType == 'CAA') && !(userAdmin || userSuperadmin)) { + return false; + } + return true; + } + // Make the selected row editable and focus the chosen cell function make_editable(element, cell) { // Get row details of this rrset diff --git a/public_html/style.css b/public_html/style.css index 8443e4a..cebb262 100644 --- a/public_html/style.css +++ b/public_html/style.css @@ -244,3 +244,14 @@ div.stickyHeader th { .javascript .nu0 {color: #CC0000;} .javascript .me1 {color: #660066;} .javascript span.xtra { display:block; } + +/* Read-only records styling */ +table.rrsets tr.read-only { + opacity: 0.7; +} +table.rrsets tr.read-only td { + cursor: not-allowed; +} +table.rrsets tr.read-only td:hover { + background-color: #f5f5f5; +} diff --git a/templates/zone.php b/templates/zone.php index 823616b..4a535b8 100644 --- a/templates/zone.php +++ b/templates/zone.php @@ -66,7 +66,7 @@

Resource records

-
+ get('active_user')->get_csrf_field(), ESC_NONE) ?> @@ -86,8 +86,6 @@ $rrsetnum = 0; foreach($rrsets as $rrset) { if($rrset->type == 'SOA') continue; - if($rrset->type == 'NS' && !($active_user->admin || $active_user->is_zone_superadmin($zone))) continue; - if($rrset->type == 'CAA' && !($active_user->admin || $active_user->is_zone_superadmin($zone))) continue; $rrsetnum++; $rrs = $rrset->list_resource_records(); $name = DNSName::abbreviate($rrset->name, $zone->name); @@ -101,7 +99,7 @@ if($alldisabled) $rowclasses[] = 'rrset-disable'; if($rrsetnum > $maxperpage) $rowclasses[] = 'hidden'; ?> - + @@ -140,7 +138,15 @@ ?> From ca673bd05199d7af014a2eebdea12d292dcb83e7 Mon Sep 17 00:00:00 2001 From: ictud Date: Tue, 5 Aug 2025 15:16:45 +0200 Subject: [PATCH 4/7] fix: Update data attribute name for zone superadmin permissions --- public_html/extra.js | 2 +- templates/zone.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public_html/extra.js b/public_html/extra.js index 79f09bc..69c867e 100644 --- a/public_html/extra.js +++ b/public_html/extra.js @@ -122,7 +122,7 @@ $(function() { var tr = element.closest('tr'); var recordType = tr.data('type'); var userAdmin = form.data('user-admin') == 1; - var userSuperadmin = form.data('user-superadmin') == 1; + var userSuperadmin = form.data('user-super-zone-admin') == 1; // NS and CAA records can only be edited by admins or super zone admins if((recordType == 'NS' || recordType == 'CAA') && !(userAdmin || userSuperadmin)) { diff --git a/templates/zone.php b/templates/zone.php index 4a535b8..c9a3301 100644 --- a/templates/zone.php +++ b/templates/zone.php @@ -66,7 +66,7 @@

Resource records

- + get('active_user')->get_csrf_field(), ESC_NONE) ?>
From 40f8e9bc264f8393ff42a2216c7d165f0cd47906 Mon Sep 17 00:00:00 2001 From: ictud Date: Tue, 5 Aug 2025 15:34:01 +0200 Subject: [PATCH 5/7] refactor: Rename superadministrator to zone-super-administrator for clarity --- migrations/007.php | 4 ++-- model/user.php | 10 +++++----- model/zone.php | 2 +- templates/zone.php | 42 +++++++++++++++++++++--------------------- views/zone.php | 12 ++++++------ 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/migrations/007.php b/migrations/007.php index b80d83a..1d322cb 100644 --- a/migrations/007.php +++ b/migrations/007.php @@ -1,4 +1,4 @@ database->exec("ALTER TYPE access_level ADD VALUE IF NOT EXISTS 'superadministrator'"); +$this->database->exec("ALTER TYPE access_level ADD VALUE IF NOT EXISTS 'zone-super-administrator'"); diff --git a/model/user.php b/model/user.php index 0cdec08..d84cc5b 100644 --- a/model/user.php +++ b/model/user.php @@ -196,12 +196,12 @@ public function access_to(Zone $zone) { } /** - * Check if this user is a zone superadministrator for the specified zone. - * @param Zone $zone to check for superadmin access - * @return bool true if user is zone superadministrator + * Check if this user is a zone super administrator for the specified zone. + * @param Zone $zone to check for super administrator access + * @return bool true if user is zone super administrator */ - public function is_zone_superadmin(Zone $zone) { - return $this->access_to($zone) === 'superadministrator'; + public function is_zone_super_administrator(Zone $zone) { + return $this->access_to($zone) === 'zone-super-administrator'; } /** diff --git a/model/zone.php b/model/zone.php index af2a626..f470d27 100644 --- a/model/zone.php +++ b/model/zone.php @@ -781,7 +781,7 @@ private function process_rrset_action($update, &$trash, &$revs_missing, &$revs_u $update->oldname = $update->name; $update->oldtype = $update->type; } - if(($update->type == 'SOA' || $update->type == 'NS' || $update->type == 'CAA') && !($active_user->admin || $active_user->is_zone_superadmin($this))) return; + if(($update->type == 'SOA' || $update->type == 'NS' || $update->type == 'CAA') && !($active_user->admin || $active_user->is_zone_super_administrator($this))) return; if(isset($config['dns']['autocreate_reverse_records'])) { $autocreate_ptr = (bool)$config['dns']['autocreate_reverse_records']; diff --git a/templates/zone.php b/templates/zone.php index c9a3301..8da3d82 100644 --- a/templates/zone.php +++ b/templates/zone.php @@ -66,7 +66,7 @@

Resource records

- + get('active_user')->get_csrf_field(), ESC_NONE) ?>
@@ -99,7 +99,7 @@ if($alldisabled) $rowclasses[] = 'rrset-disable'; if($rrsetnum > $maxperpage) $rowclasses[] = 'hidden'; ?> - + @@ -139,7 +139,7 @@ - + admin) { ?> From 1745a5795389162885aa979f51b958e1fd02d7bf Mon Sep 17 00:00:00 2001 From: ictud Date: Wed, 6 Aug 2025 10:53:53 +0200 Subject: [PATCH 7/7] security: implement multi-layer protection for NS/SOA/CAA record editing --- model/zone.php | 22 +++++++++++++++++++++- views/zone.php | 29 ++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/model/zone.php b/model/zone.php index f470d27..362b81c 100644 --- a/model/zone.php +++ b/model/zone.php @@ -501,6 +501,22 @@ public function export_as_bind9_format() { */ public function add_pending_update($update) { global $active_user; + + // Security check: Prevent creation of pending updates for restricted record types + // Only global admins and zone super administrators can request changes to SOA, NS, and CAA records + $update_data = json_decode($update); + if($update_data && isset($update_data->actions)) { + foreach($update_data->actions as $action) { + if(($action->type == 'SOA' || $action->type == 'NS' || $action->type == 'CAA') || + (isset($action->oldtype) && ($action->oldtype == 'SOA' || $action->oldtype == 'NS' || $action->oldtype == 'CAA'))) { + if(!($active_user->admin || $active_user->is_zone_super_administrator($this))) { + throw new RuntimeException('You are not authorized to request changes to SOA, NS, or CAA records.'); + } + break; + } + } + } + $stmt = $this->database->prepare('INSERT INTO pending_update (zone_id, author_id, request_date, raw_data) VALUES (?, ?, NOW(), ?)'); $stmt->bindParam(1, $this->id, PDO::PARAM_INT); $stmt->bindParam(2, $active_user->id, PDO::PARAM_INT); @@ -781,7 +797,11 @@ private function process_rrset_action($update, &$trash, &$revs_missing, &$revs_u $update->oldname = $update->name; $update->oldtype = $update->type; } - if(($update->type == 'SOA' || $update->type == 'NS' || $update->type == 'CAA') && !($active_user->admin || $active_user->is_zone_super_administrator($this))) return; + // Security check: Only global admins and zone super administrators can edit SOA, NS and CAA records. + // We must check BOTH the new type and the original type to prevent privilege-escalation via rename/delete. + if((($update->type == 'SOA' || $update->type == 'NS' || $update->type == 'CAA') || + (isset($update->oldtype) && ($update->oldtype == 'SOA' || $update->oldtype == 'NS' || $update->oldtype == 'CAA'))) + && !($active_user->admin || $active_user->is_zone_super_administrator($this))) return; if(isset($config['dns']['autocreate_reverse_records'])) { $autocreate_ptr = (bool)$config['dns']['autocreate_reverse_records']; diff --git a/views/zone.php b/views/zone.php index a4f0105..3d78f26 100644 --- a/views/zone.php +++ b/views/zone.php @@ -126,7 +126,34 @@ $content->set('message', $e->getMessage()); } } else { - $zone->add_pending_update(json_encode($json)); + // Security check: Prevent creation of pending updates for restricted record types + // Only global admins and zone super administrators can request changes to SOA, NS, and CAA records + $restricted_changes = false; + foreach($json->actions as $action) { + if(($action->type == 'SOA' || $action->type == 'NS' || $action->type == 'CAA') || + (isset($action->oldtype) && ($action->oldtype == 'SOA' || $action->oldtype == 'NS' || $action->oldtype == 'CAA'))) { + $restricted_changes = true; + break; + } + } + + if($restricted_changes && !($active_user->admin || $active_user->is_zone_super_administrator($zone))) { + $alert = new UserAlert; + $alert->content = "You are not authorized to request changes to SOA, NS, or CAA records."; + $alert->class = "error"; + $active_user->add_alert($alert); + redirect(); + } + + try { + $zone->add_pending_update(json_encode($json)); + } catch(RuntimeException $e) { + $alert = new UserAlert; + $alert->content = $e->getMessage(); + $alert->class = "error"; + $active_user->add_alert($alert); + redirect(); + } $mail = new Email; // Mail SOA contact and administrators/super zone administrators about pending update $mail->add_recipient(preg_replace('/^([^\.]+)\./', '$1@', trim($zone->soa->contact, '.')));