diff --git a/migrations/007.php b/migrations/007.php new file mode 100644 index 0000000..1d322cb --- /dev/null +++ b/migrations/007.php @@ -0,0 +1,4 @@ +database->exec("ALTER TYPE access_level ADD VALUE IF NOT EXISTS 'zone-super-administrator'"); 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..d84cc5b 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 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_super_administrator(Zone $zone) { + return $this->access_to($zone) === 'zone-super-administrator'; + } + /** * 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..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') && !$active_user->admin) 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/public_html/extra.js b/public_html/extra.js index 6659f6b..69c867e 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-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)) { + 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..7170475 100644 --- a/public_html/style.css +++ b/public_html/style.css @@ -244,3 +244,22 @@ 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; +} + +/* User info in navbar */ +.navbar-text .glyphicon-user { + margin-right: 5px; +} +.navbar-text { + color: #777; +} diff --git a/templates/base.php b/templates/base.php index 2f16b54..951c46e 100644 --- a/templates/base.php +++ b/templates/base.php @@ -68,6 +68,14 @@ + get('active_user'))) { ?> + + diff --git a/templates/zone.php b/templates/zone.php index 09c395e..7936b81 100644 --- a/templates/zone.php +++ b/templates/zone.php @@ -66,7 +66,7 @@

Resource records

-
+ get('active_user')->get_csrf_field(), ESC_NONE) ?> @@ -86,7 +86,6 @@ $rrsetnum = 0; foreach($rrsets as $rrset) { if($rrset->type == 'SOA') continue; - if($rrset->type == 'NS' && !$active_user->admin) continue; $rrsetnum++; $rrs = $rrset->list_resource_records(); $name = DNSName::abbreviate($rrset->name, $zone->name); @@ -100,7 +99,7 @@ if($alldisabled) $rowclasses[] = 'rrset-disable'; if($rrsetnum > $maxperpage) $rowclasses[] = 'hidden'; ?> - + @@ -139,7 +138,15 @@ ?> @@ -164,7 +171,7 @@ - admin) { ?> + admin || $active_user->is_zone_super_administrator($zone)) { ?> @@ -172,7 +179,9 @@ + admin || $active_user->is_zone_super_administrator($zone)) { ?> + @@ -180,7 +189,7 @@ - admin) { ?> + admin || $active_user->is_zone_super_administrator($zone)) { ?> @@ -219,9 +228,10 @@
-
>
+
> +
- admin || $active_user->access_to($zone) == 'administrator') && !$force_change_review) { ?> + admin || $active_user->access_to($zone) == 'administrator' || $active_user->is_zone_super_administrator($zone)) && !$force_change_review) { ?>

@@ -259,7 +269,7 @@
- admin || $active_user->access_to($zone) == 'administrator') { ?> + admin || $active_user->access_to($zone) == 'administrator' || $active_user->is_zone_super_administrator($zone)) { ?> @@ -367,7 +377,7 @@
- admin) { ?> + admin || $active_user->is_zone_super_administrator($zone)) { ?> @@ -405,7 +415,7 @@

Start of authority (SOA)

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

soa->primary_ns)?>

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

soa->contact)?>

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

soa->refresh))?>

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

soa->retry))?>

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

soa->expiry))?>

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

soa->default_ttl))?>

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

soa->ttl))?>

@@ -493,7 +503,7 @@

- admin) { ?> + admin || $active_user->is_zone_super_administrator($zone)) { ?>
@@ -761,7 +771,21 @@ function pagination($page) {
- + admin) { ?> @@ -783,6 +807,12 @@ function pagination($page) {
+
+ +
+ diff --git a/views/zone.php b/views/zone.php index b9b86a5..3d78f26 100644 --- a/views/zone.php +++ b/views/zone.php @@ -110,7 +110,7 @@ foreach($_POST['updates'] as $update) { $json->actions[] = json_decode($update); } - if(($active_user->admin || $active_user->access_to($zone) == 'administrator') && !$force_change_review) { + if(($active_user->admin || $active_user->access_to($zone) == 'administrator' || $active_user->is_zone_super_administrator($zone)) && !$force_change_review) { try { $zone->process_bulk_json_rrset_update(json_encode($json)); redirect(); @@ -126,12 +126,39 @@ $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 about pending update + // Mail SOA contact and administrators/super zone administrators about pending update $mail->add_recipient(preg_replace('/^([^\.]+)\./', '$1@', trim($zone->soa->contact, '.'))); foreach($zone->list_access() as $access) { - if($access->level == 'administrator') { + if($access->level == 'administrator' || $access->level == 'zone-super-administrator') { $mail->add_recipient($access->user->email, $access->user->name); } } @@ -158,7 +185,7 @@ $zone->delete_pending_update($update); } redirect(); - } elseif(isset($_POST['approve_update']) && ($active_user->admin || $active_user->access_to($zone) == 'administrator')) { + } elseif(isset($_POST['approve_update']) && ($active_user->admin || $active_user->access_to($zone) == 'administrator' || $active_user->is_zone_super_administrator($zone))) { try { $update = $zone->get_pending_update_by_id($_POST['approve_update']); } catch(PendingUpdateNotFound $e) { @@ -189,7 +216,7 @@ $content = new PageSection('zone_update_failed'); $content->set('message', $e->getMessage()); } - } elseif(isset($_POST['reject_update']) && ($active_user->admin || $active_user->access_to($zone) == 'administrator')) { + } elseif(isset($_POST['reject_update']) && ($active_user->admin || $active_user->access_to($zone) == 'administrator' || $active_user->is_zone_super_administrator($zone))) { try { $update = $zone->get_pending_update_by_id($_POST['reject_update']); } catch(PendingUpdateNotFound $e) { @@ -213,7 +240,7 @@ $alert->content = "Change request rejected."; $active_user->add_alert($alert); redirect(); - } elseif(isset($_POST['update_zone']) && ($active_user->admin || $active_user->access_to($zone) == 'administrator')) { + } elseif(isset($_POST['update_zone']) && ($active_user->admin || $active_user->access_to($zone) == 'administrator' || $active_user->is_zone_super_administrator($zone))) { $zone->kind = $_POST['kind']; $zone->account = $_POST['classification']; $zone->update();