Skip to content

Commit

Permalink
Merge branch '5' into 6.0
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Feb 20, 2025
2 parents 4972b0d + a4227a9 commit 1a6cea4
Show file tree
Hide file tree
Showing 19 changed files with 447 additions and 22 deletions.
17 changes: 14 additions & 3 deletions src/Control/Middleware/AllowedHostsMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace SilverStripe\Control\Middleware;

use InvalidArgumentException;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
Expand All @@ -12,14 +13,16 @@
class AllowedHostsMiddleware implements HTTPMiddleware
{
/**
* List of allowed hosts
* List of allowed hosts.
* Can be ['*'] to allow all hosts and disable the logged warning.
*
* @var array
*/
private $allowedHosts = [];

/**
* @return array List of allowed Host header values
* @return array List of allowed Host header values.
* Note that both an empty array and ['*'] can be used to allow all hosts.
*/
public function getAllowedHosts()
{
Expand All @@ -30,14 +33,21 @@ public function getAllowedHosts()
* Sets the list of allowed Host header values
* Can also specify a comma separated list
*
* Note that both an empty array and ['*'] can be used to allow all hosts.
*
* @param array|string $allowedHosts
* @return $this
*/
public function setAllowedHosts($allowedHosts)
{
if (is_string($allowedHosts)) {
if ($allowedHosts === null) {
$allowedHosts = [];
} elseif (is_string($allowedHosts)) {
$allowedHosts = preg_split('/ *, */', $allowedHosts ?? '');
}
if (count($allowedHosts) > 1 && in_array('*', $allowedHosts)) {
throw new InvalidArgumentException('The wildcard "*" cannot be used in conjunction with actual hosts.');
}
$this->allowedHosts = $allowedHosts;
return $this;
}
Expand All @@ -51,6 +61,7 @@ public function process(HTTPRequest $request, callable $delegate)

// check allowed hosts
if ($allowedHosts
&& $allowedHosts !== ['*']
&& !Director::is_cli()
&& !in_array($request->getHeader('Host'), $allowedHosts ?? [])
) {
Expand Down
28 changes: 28 additions & 0 deletions src/Core/BaseKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\Control\Middleware\AllowedHostsMiddleware;
use SilverStripe\Core\Cache\ManifestCacheFactory;
use SilverStripe\Core\Config\ConfigLoader;
use SilverStripe\Core\Config\CoreConfigFactory;
Expand Down Expand Up @@ -221,6 +222,9 @@ protected function bootConfigs()
{
// After loading all other app manifests, include _config.php files
$this->getModuleLoader()->getManifest()->activateConfig();

// Ensure everything is set up correctly
$this->validateConfiguration();
}

/**
Expand Down Expand Up @@ -362,6 +366,7 @@ public function activate()
$this->getInjectorLoader()
->getManifest()
->registerService($this, Kernel::class);

return $this;
}

Expand Down Expand Up @@ -443,4 +448,27 @@ public function setThemeResourceLoader($themeResourceLoader)
$this->themeResourceLoader = $themeResourceLoader;
return $this;
}

/**
* Validate configuration of the application is in a good state, ready for use.
*
* This method can be used to warn developers of any misconfiguration, or configuration
* which is missing but should be set according to best practice.
*
* In some cases, this could be used to halt execution if configuration critical to operation
* has not been set.
*/
protected function validateConfiguration(): void
{
// Log a warning if allowed hosts hasn't been configured.
// This can include wildcard, but it must be explicitly set to ensure the developer is aware
// of the level of protection their application has against host header injection attacks.
$allowedHostsMiddleware = Injector::inst()->get(AllowedHostsMiddleware::class, true);
if (empty($allowedHostsMiddleware->getAllowedHosts())) {
Injector::inst()->get(LoggerInterface::class)->warning(
'Allowed hosts has not been set. Your application could be vulnerable to host header injection attacks.'
. ' Either set the SS_ALLOWED_HOSTS environment variable or the AllowedHosts property on ' . AllowedHostsMiddleware::class
);
}
}
}
43 changes: 43 additions & 0 deletions src/Forms/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace SilverStripe\Forms;

use BadMethodCallException;
use SilverStripe\Admin\LeftAndMain;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HasRequestHandler;
use SilverStripe\Control\HTTPRequest;
Expand All @@ -22,6 +23,8 @@
use SilverStripe\View\SSViewer;
use SilverStripe\Model\ModelData;
use SilverStripe\Forms\Validation\Validator;
use SilverStripe\Security\SudoMode\SudoModeServiceInterface;
use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest;

/**
* Base class for all forms.
Expand Down Expand Up @@ -320,6 +323,46 @@ public function __construct(
$this->setupDefaultClasses();
}

/**
* Make the form require sudo mode, which will make the form readonly and add a sudo mode password field
* unless the current user previously activated sudo mode.
*
* Note that if the parent request handler for this form isn't LeftAndMain or GridFieldDetailForm_ItemRequest,
* sudo mode will not be required by this form.
*/
public function requireSudoMode(): void
{
// Check that the current request handler for the form is one that's used
// in an admin context where sudo mode makes sense
$classes = [
LeftAndMain::class,
GridFieldDetailForm_ItemRequest::class,
];
$enableSudoMode = false;
foreach ($classes as $class) {
if (is_a($this->getController(), $class)) {
$enableSudoMode = true;
break;
}
}
if (!$enableSudoMode) {
return;
}
// Check if sudo mode is currently enabled
$service = Injector::inst()->get(SudoModeServiceInterface::class);
$session = $this->getRequest()->getSession();
if ($service->check($session)) {
return;
}
// If sudo mode is not active, make the form readonly and add a sudo mode password field
$this->makeReadonly();
$field = SudoModePasswordField::create(SudoModePasswordField::FIELD_NAME);
// Manually call setForm() to the field as the field list is being updated after the
// form is created, which is when setForm() is normally being created
$field->setForm($this);
$this->Fields()->unshift($field);
}

/**
* @return bool
*/
Expand Down
36 changes: 35 additions & 1 deletion src/Forms/GridField/GridField.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormField;
use SilverStripe\Forms\GridField\FormAction\SessionStore;
use SilverStripe\Forms\GridField\FormAction\StateStore;
use SilverStripe\Model\List\ArrayList;
use SilverStripe\ORM\DataList;
Expand All @@ -25,6 +24,9 @@
use SilverStripe\Model\List\SS_List;
use SilverStripe\View\HTML;
use SilverStripe\Model\ModelData;
use SilverStripe\Security\SudoMode\SudoModeServiceInterface;
use SilverStripe\ORM\DataObject;
use SilverStripe\Forms\GridField\GridFieldViewButton;

/**
* Displays a {@link SS_List} in a grid format.
Expand Down Expand Up @@ -526,6 +528,31 @@ public function FieldHolder($properties = [])
{
$this->extend('onBeforeRenderHolder', $this, $properties);

// Set GridField to read-only if sudo mode is required for the DataObject being managed
// and sudo mode is not active
$sudoModeTransformation = false;
$modelClass = null;
try {
$modelClass = $this->getModelClass();
} catch (LogicException) {
// noop - it's possible to have a gridfield with custom components that don't rely on columns
// from the records in the list.
}
if (is_a($modelClass, DataObject::class, true)) {
/** @var DataObject $obj */
$obj = Injector::inst()->create($modelClass);
if ($obj->getRequireSudoMode()) {
$session = Controller::curr()?->getRequest()?->getSession();
if ($session) {
$service = Injector::inst()->get(SudoModeServiceInterface::class);
if (!$service->check($session)) {
$this->performReadonlyTransformation();
$sudoModeTransformation = true;
}
}
}
}

$columns = $this->getColumns();

$list = $this->getManipulatedList();
Expand Down Expand Up @@ -557,6 +584,13 @@ public function FieldHolder($properties = [])
if ($item instanceof GridFieldPaginator) {
$total = $item->getTotalItems();
}
if ($sudoModeTransformation) {
// Modify the GridFieldViewButton on any GridFields so that it doesn't suffix the view URL with 'view'
// This allows us to gracefully reload a form in readonly mode when sudo mode is activated
if ($item instanceof GridFieldViewButton) {
$item->setSuffixViewToUrl(false);
}
}
}

foreach ($content as $contentKey => $contentValue) {
Expand Down
8 changes: 8 additions & 0 deletions src/Forms/GridField/GridFieldDetailForm_ItemRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,15 @@ public function ItemEditForm()
if ($cb) {
$cb($form, $this);
}

$this->extend("updateItemEditForm", $form);

// Check if the the record is a DataObject and if that DataObject requires sudo mode
// If so then require sudo mode for the item edit form
if (is_a($this->record, DataObject::class) && $this->record->getRequireSudoMode()) {
$form->requireSudoMode();
}

return $form;
}

Expand Down
19 changes: 18 additions & 1 deletion src/Forms/GridField/GridFieldViewButton.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@
*/
class GridFieldViewButton extends AbstractGridFieldComponent implements GridField_ColumnProvider, GridField_ActionMenuLink
{
private bool $suffixViewToUrl = true;

public function getSuffixViewToUrl(): bool
{
return $this->suffixViewToUrl;
}

public function setSuffixViewToUrl(bool $suffixViewToUrl): static
{
$this->suffixViewToUrl = $suffixViewToUrl;
return $this;
}

/**
* @inheritdoc
*/
Expand Down Expand Up @@ -44,7 +57,11 @@ public function getExtraData($gridField, $record, $columnName)
*/
public function getUrl($gridField, $record, $columnName)
{
$link = Controller::join_links($gridField->Link('item'), $record->ID, 'view');
$parts = [$gridField->Link('item'), $record->ID];
if ($this->getSuffixViewToUrl()) {
$parts[] = 'view';
}
$link = Controller::join_links(...$parts);
return $gridField->addAllStateToUrl($link);
}

Expand Down
29 changes: 29 additions & 0 deletions src/Forms/SudoModePasswordField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace SilverStripe\Forms;

use SilverStripe\Forms\PasswordField;

class SudoModePasswordField extends PasswordField
{
public const FIELD_NAME = 'SudoModePasswordField';

protected $schemaComponent = 'SudoModePasswordField';

public function __construct()
{
// Name must be "SudoModePasswordField" as there's logic elsewhere expecting this
// $title and $value are set to null as the react component does not use these arguments
parent::__construct(SudoModePasswordField::FIELD_NAME);
// Set title to empty string to avoid rendering a label before the react component has loaded
$this->setTitle('');
$this->addExtraClass('SudoModePasswordField');
}

public function performReadonlyTransformation()
{
// Readonly transformation should not be applied to this field
// as this field is intended to be used on a form that has been set to read only mode
return $this;
}
}
15 changes: 15 additions & 0 deletions src/Model/ModelData.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ class ModelData
*/
private array $dynamicData = [];

/**
* Config of whether the model requires sudo mode to be active in order to be modified in admin
* Sudo mode is a security feature that requires the user to re-enter their password before
* making changes to the database.
*/
private static bool $require_sudo_mode = false;

// -----------------------------------------------------------------------------------------------------------------

/**
Expand Down Expand Up @@ -218,6 +225,14 @@ public function hasDynamicData(string $field): bool
return array_key_exists($field, $this->dynamicData);
}

/**
* Whether the model requires sudo mode to be active in order to be modified in admin
*/
public function getRequireSudoMode(): bool
{
return static::config()->get('require_sudo_mode');
}

/**
* Returns true if a method exists for the current class which isn't private.
* Also returns true for private methods if $this is ModelData (not a subclass)
Expand Down
2 changes: 2 additions & 0 deletions src/Security/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ class Group extends DataObject
'Code' => true,
'Sort' => true,
];

private static bool $require_sudo_mode = true;

public function getAllChildren()
{
Expand Down
2 changes: 2 additions & 0 deletions src/Security/Member.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ class Member extends DataObject
//'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true)
];

private static bool $require_sudo_mode = true;

/**
* @config
* @var boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,6 @@ public function logIn(Member $member, $persistent = false, ?HTTPRequest $request
if (Member::config()->get('login_marker_cookie')) {
Cookie::set(Member::config()->get('login_marker_cookie'), 1, 0);
}

// Activate sudo mode on login so the user doesn't have to reauthenticate for sudo
// actions until the sudo mode timeout expires
$service = Injector::inst()->get(SudoModeServiceInterface::class);
$service->activate($session);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/Security/Permission.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
'EDIT_PERMISSIONS'
];

private static bool $require_sudo_mode = true;

/**
* Check that the current member has the given permission.
*
Expand Down
2 changes: 2 additions & 0 deletions src/Security/PermissionRole.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class PermissionRole extends DataObject

private static $plural_name = 'Roles';

private static bool $require_sudo_mode = true;

public function getCMSFields()
{
$fields = parent::getCMSFields();
Expand Down
Loading

0 comments on commit 1a6cea4

Please sign in to comment.