'csp_script_src',
'style-src' => 'csp_style_src',
'img-src' => 'csp_img_src',
'font-src' => 'csp_font_src',
'connect-src' => 'csp_connect_src',
'frame-src' => 'csp_frame_src',
'media-src' => 'csp_media_src',
'worker-src' => 'csp_worker_src',
'form-action' => 'csp_form_action',
'base-uri' => 'csp_base_uri',
'object-src' => 'csp_object_src',
'default-src' => 'csp_default_src',
);
private static $booted = false;
private static $instance = null;
/**
* @return self
*/
public static function instance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function __construct() {
if (self::$booted) {
return;
}
self::$booted = true;
self::$instance = $this;
ASH_CSP_Repository::maybe_install();
if (!wp_next_scheduled('ash_csp_assistant_cron')) {
wp_schedule_event(time() + 120, 'hourly', 'ash_csp_assistant_cron');
}
add_action('init', array($this, 'maybe_expire_learning'));
add_action('ash_csp_assistant_cron', array($this, 'cron_tick'));
add_action('send_headers', array($this, 'send_report_only_header'), 2);
add_action('wp_enqueue_scripts', array($this, 'enqueue_runtime_observer'), 5);
add_action('login_enqueue_scripts', array($this, 'enqueue_runtime_observer'), 5);
add_action('wp_footer', array($this, 'collect_frontend_static'), 9999);
add_action('login_footer', array($this, 'collect_frontend_static'), 9999);
add_action('wp_ajax_ash_csp_assistant_state', array($this, 'ajax_state'));
add_action('wp_ajax_ash_csp_assistant_start', array($this, 'ajax_start'));
add_action('wp_ajax_ash_csp_assistant_stop', array($this, 'ajax_stop'));
add_action('wp_ajax_ash_csp_assistant_ignore', array($this, 'ajax_ignore'));
add_action('wp_ajax_ash_csp_assistant_apply', array($this, 'ajax_apply'));
add_action('wp_ajax_ash_csp_assistant_diff', array($this, 'ajax_diff'));
add_action('wp_ajax_ash_csp_assistant_clear', array($this, 'ajax_clear'));
add_action('wp_ajax_ash_csp_assistant_details', array($this, 'ajax_details'));
add_action('wp_ajax_ash_csp_assistant_continuous', array($this, 'ajax_continuous'));
add_action('wp_ajax_ash_csp_assistant_disk_toggle', array($this, 'ajax_disk_toggle'));
add_action('wp_ajax_ash_csp_assistant_disk_prepare', array($this, 'ajax_disk_prepare'));
add_action('wp_ajax_ash_csp_assistant_disk_tick', array($this, 'ajax_disk_tick'));
add_action('wp_ajax_ash_csp_assistant_disk_cancel', array($this, 'ajax_disk_cancel'));
add_action('wp_ajax_ash_csp_assistant_disk_exclusions', array($this, 'ajax_disk_exclusions'));
add_action('wp_ajax_ash_csp_assistant_disk_scope', array($this, 'ajax_disk_scope'));
add_action('wp_ajax_ash_csp_report', array($this, 'ajax_report'));
add_action('wp_ajax_nopriv_ash_csp_report', array($this, 'ajax_report'));
add_action('wp_ajax_ash_csp_runtime', array($this, 'ajax_runtime'));
add_action('wp_ajax_nopriv_ash_csp_runtime', array($this, 'ajax_runtime'));
}
/**
* @return array
*/
public static function default_state() {
return array(
'status' => 'not_scanned',
'duration' => '1hour',
'started_at' => '',
'ends_at' => '',
'token' => '',
'continuous_monitoring' => 0,
'disk_scan_enabled' => 0,
'last_review_at' => '',
);
}
/**
* @return array
*/
public static function get_state() {
$state = get_option(self::STATE_OPTION, array());
if (!is_array($state)) {
$state = array();
}
return array_merge(self::default_state(), $state);
}
/**
* @param array $state State values.
* @return void
*/
public static function save_state($state) {
update_option(self::STATE_OPTION, array_merge(self::get_state(), $state), false);
}
/**
* Observation is active during timed learning or optional continuous monitoring.
*
* @return bool
*/
public static function is_observing() {
$state = self::get_state();
if ($state['status'] === 'learning') {
return true;
}
return !empty($state['continuous_monitoring']) && $state['status'] !== 'not_scanned';
}
/**
* @return bool
*/
public static function is_learning() {
return self::get_state()['status'] === 'learning';
}
/**
* Stop learning when the selected duration has elapsed.
*
* @return void
*/
public function maybe_expire_learning() {
$state = self::get_state();
if ($state['status'] !== 'learning' || $state['duration'] === 'manual' || $state['ends_at'] === '') {
return;
}
if (!empty($state['disk_scan_enabled']) || ASH_CSP_Disk_Scanner::is_active()) {
return;
}
if (time() >= (int) $state['ends_at']) {
self::complete_learning();
}
}
/**
* @return void
*/
public function cron_tick() {
$this->maybe_expire_learning();
ASH_CSP_Repository::expire_old();
}
/**
* Send Content-Security-Policy-Report-Only while observing, without blocking.
*
* @return void
*/
public function send_report_only_header() {
if (headers_sent() || !self::is_observing() || $this->is_admin_request()) {
return;
}
if (self::is_learning()) {
header_remove('Content-Security-Policy');
header_remove('X-Content-Security-Policy');
header_remove('X-WebKit-CSP');
}
$policy = $this->build_report_only_policy();
if ($policy !== '') {
@header('Content-Security-Policy-Report-Only: ' . $policy);
}
}
/**
* @return void
*/
public function enqueue_runtime_observer() {
if (!self::is_observing() || $this->is_admin_request()) {
return;
}
$state = self::get_state();
$path = ASH_PLUGIN_DIR . 'assets/js/csp-runtime-observer.js';
$version = file_exists($path) ? (string) filemtime($path) : ASH_VERSION;
wp_enqueue_script(
'ash-csp-runtime-observer',
ASH_PLUGIN_URL . 'assets/js/csp-runtime-observer.js',
array(),
$version,
true
);
wp_localize_script('ash-csp-runtime-observer', 'ashCspObserver', array(
'enabled' => true,
'endpoint' => admin_url('admin-ajax.php'),
'token' => $state['token'],
'page' => ASH_CSP_Normalizer::page_path(isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '/'),
));
}
/**
* Snapshot actually enqueued frontend assets during observation.
*
* @return void
*/
public function collect_frontend_static() {
if (!self::is_observing() || $this->is_admin_request()) {
return;
}
$page = ASH_CSP_Normalizer::page_path(isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '/');
ASH_CSP_Static_Detector::collect($page, true);
}
/**
* @return void
*/
public function ajax_state() {
$this->require_admin();
wp_send_json_success($this->payload());
}
/**
* @return void
*/
public function ajax_start() {
$this->require_admin();
$duration = isset($_POST['duration']) ? sanitize_key(wp_unslash($_POST['duration'])) : '1hour';
$allowed = array('15min', '1hour', '6hours', '24hours', 'manual');
if (!in_array($duration, $allowed, true)) {
$duration = '1hour';
}
$disk_enabled = !empty($_POST['disk_scan']) || !empty(self::get_state()['disk_scan_enabled']);
if ($disk_enabled) {
$duration = 'manual';
}
$now = time();
$ends = 0;
if ($duration === '15min') {
$ends = $now + 15 * MINUTE_IN_SECONDS;
} elseif ($duration === '1hour') {
$ends = $now + HOUR_IN_SECONDS;
} elseif ($duration === '6hours') {
$ends = $now + 6 * HOUR_IN_SECONDS;
} elseif ($duration === '24hours') {
$ends = $now + DAY_IN_SECONDS;
}
self::save_state(array(
'status' => 'learning',
'duration' => $duration,
'started_at' => (string) $now,
'ends_at' => $ends ? (string) $ends : '',
'token' => wp_generate_password(32, false, false),
'disk_scan_enabled' => $disk_enabled ? 1 : 0,
));
if ($disk_enabled) {
ASH_CSP_Disk_Scanner::reset();
}
ASH_CSP_Static_Detector::collect('/', false);
wp_send_json_success($this->payload());
}
/**
* @return void
*/
public function ajax_stop() {
$this->require_admin();
self::complete_learning();
wp_send_json_success($this->payload());
}
/**
* @return void
*/
public function ajax_ignore() {
$this->require_admin();
$id = isset($_POST['id']) ? (int) $_POST['id'] : 0;
if ($id <= 0 || !ASH_CSP_Repository::set_status($id, 'ignored')) {
wp_send_json_error(array('message' => __('Unable to ignore this source.', 'abdal-security-headers')));
}
wp_send_json_success($this->payload());
}
/**
* @return void
*/
public function ajax_details() {
$this->require_admin();
$id = isset($_POST['id']) ? (int) $_POST['id'] : 0;
$row = ASH_CSP_Repository::get_source($id);
if (!$row) {
wp_send_json_error(array('message' => __('Source not found.', 'abdal-security-headers')));
}
wp_send_json_success($this->format_source($row));
}
/**
* Saved plugin options used to compare discovered origins against CSP fields.
*
* @return array
*/
private function plugin_options() {
$options = get_option('ash_options', array());
return is_array($options) ? $options : array();
}
/**
* Whether a discovered origin is already present in the matching CSP field.
*
* @param array $row Database row.
* @param array $options Saved plugin options.
* @return bool
*/
private function source_in_policy($row, $options) {
$directive = isset($row['directive']) ? $row['directive'] : '';
$origin = isset($row['origin']) ? $row['origin'] : '';
if ($directive === '' || $directive === 'unknown' || $origin === '') {
return false;
}
if (isset(self::OPTION_MAP[$directive])) {
$option_key = self::OPTION_MAP[$directive];
$value = isset($options[$option_key]) ? (string) $options[$option_key] : '';
if (ASH_CSP_Normalizer::policy_covers_origin($value, $origin)) {
return true;
}
if (trim($value) !== '' || $directive === 'default-src') {
return false;
}
}
$default = isset($options['csp_default_src']) ? (string) $options['csp_default_src'] : '';
return ASH_CSP_Normalizer::policy_covers_origin($default, $origin);
}
/**
* @return void
*/
public function ajax_diff() {
$this->require_admin();
$ids = isset($_POST['ids']) ? (array) $_POST['ids'] : array();
wp_send_json_success(array(
'diff' => $this->build_diff($ids),
));
}
/**
* Merge selected origins into existing CSP options. Current values are never replaced.
*
* @return void
*/
public function ajax_apply() {
$this->require_admin();
$ids = isset($_POST['ids']) ? (array) $_POST['ids'] : array();
$confirm_dangerous = !empty($_POST['confirm_dangerous']);
$diff = $this->build_diff($ids);
if (empty($diff['changes'])) {
wp_send_json_error(array('message' => __('No eligible sources were selected.', 'abdal-security-headers')));
}
if ($diff['has_dangerous'] && !$confirm_dangerous) {
wp_send_json_error(array(
'message' => __('Dangerous CSP values require explicit confirmation.', 'abdal-security-headers'),
'diff' => $diff,
));
}
$options = get_option('ash_options', array());
if (!is_array($options)) {
$options = array();
}
foreach ($diff['changes'] as $change) {
if (empty($change['option']) || empty($change['proposed'])) {
continue;
}
if (!empty($change['dangerous']) && !$confirm_dangerous) {
continue;
}
$options[$change['option']] = $change['proposed'];
}
update_option('ash_options', $options);
foreach ($diff['applied_ids'] as $id) {
ASH_CSP_Repository::set_status((int) $id, 'applied');
}
$payload = $this->payload();
$payload['updated_options'] = array();
foreach ($diff['changes'] as $change) {
$payload['updated_options'][$change['option']] = $change['proposed'];
}
$payload['message'] = __('Selected sources were merged into your current CSP directives.', 'abdal-security-headers');
wp_send_json_success($payload);
}
/**
* @return void
*/
public function ajax_clear() {
$this->require_admin();
ASH_CSP_Disk_Scanner::reset();
ASH_CSP_Repository::clear_all();
self::save_state(self::default_state());
wp_send_json_success($this->payload());
}
/**
* @return void
*/
public function ajax_continuous() {
$this->require_admin();
$enabled = !empty($_POST['enabled']) ? 1 : 0;
$state = array('continuous_monitoring' => $enabled);
if ($enabled && self::get_state()['token'] === '') {
$state['token'] = wp_generate_password(32, false, false);
}
if ($enabled && self::get_state()['status'] === 'not_scanned') {
$state['status'] = 'monitoring';
}
self::save_state($state);
wp_send_json_success($this->payload());
}
/**
* Persist the deep file scan switch and lock learning duration to Manual.
*
* @return void
*/
public function ajax_disk_toggle() {
$this->require_admin();
$enabled = !empty($_POST['enabled']) ? 1 : 0;
$state = array('disk_scan_enabled' => $enabled);
if ($enabled) {
$state['duration'] = 'manual';
$state['ends_at'] = '';
}
self::save_state($state);
if (!$enabled && ASH_CSP_Disk_Scanner::is_active()) {
ASH_CSP_Disk_Scanner::cancel();
}
wp_send_json_success($this->payload());
}
/**
* Count plugin and theme files before the progress loop.
*
* @return void
*/
public function ajax_disk_prepare() {
$this->require_admin();
if (empty(self::get_state()['disk_scan_enabled'])) {
wp_send_json_error(array('message' => __('Deep file scan is turned off.', 'abdal-security-headers')));
}
ASH_CSP_Disk_Scanner::prepare();
wp_send_json_success($this->payload());
}
/**
* Process the next batch of on-disk files.
*
* @return void
*/
public function ajax_disk_tick() {
$this->require_admin();
if (empty(self::get_state()['disk_scan_enabled']) && !ASH_CSP_Disk_Scanner::is_active()) {
wp_send_json_error(array('message' => __('Deep file scan is turned off.', 'abdal-security-headers')));
}
ASH_CSP_Disk_Scanner::tick();
wp_send_json_success($this->payload());
}
/**
* Stop an in-progress on-disk scan without ending learning mode.
*
* @return void
*/
public function ajax_disk_cancel() {
$this->require_admin();
ASH_CSP_Disk_Scanner::cancel();
wp_send_json_success($this->payload());
}
/**
* Save or restore the Deep file scan skip list.
*
* @return void
*/
public function ajax_disk_exclusions() {
$this->require_admin();
if (ASH_CSP_Disk_Scanner::is_active()) {
wp_send_json_error(array('message' => __('Excluded paths cannot be changed while a file scan is running.', 'abdal-security-headers')));
}
if (!empty($_POST['reset'])) {
ASH_CSP_Disk_Scanner::restore_default_exclusions();
} else {
$raw = isset($_POST['exclusions']) ? wp_unslash($_POST['exclusions']) : array();
if (!is_array($raw)) {
$raw = array();
}
ASH_CSP_Disk_Scanner::save_exclusions($raw);
}
wp_send_json_success($this->payload());
}
/**
* Save the selected-plugin/theme allow-list for Deep file scan.
*
* @return void
*/
public function ajax_disk_scope() {
$this->require_admin();
if (ASH_CSP_Disk_Scanner::is_active()) {
wp_send_json_error(array('message' => __('The scan scope cannot be changed while a file scan is running.', 'abdal-security-headers')));
}
$current = ASH_CSP_Disk_Scanner::get_scope();
$enabled = array_key_exists('enabled', $_POST) ? !empty($_POST['enabled']) : !empty($current['enabled']);
$targets = $current['targets'];
if (!empty($_POST['replace_targets'])) {
$raw = isset($_POST['targets']) ? wp_unslash($_POST['targets']) : array();
$targets = is_array($raw) ? $raw : array();
}
ASH_CSP_Disk_Scanner::save_scope($enabled, $targets);
wp_send_json_success($this->payload());
}
/**
* Accept CSP Report-Only violation reports. Stores origins only.
*
* @return void
*/
public function ajax_report() {
if (!self::is_observing() || !$this->allow_ingest()) {
status_header(204);
exit;
}
$raw = file_get_contents('php://input');
if (!is_string($raw) || $raw === '') {
status_header(204);
exit;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
status_header(204);
exit;
}
$report = array();
if (isset($data['csp-report']) && is_array($data['csp-report'])) {
$report = $data['csp-report'];
} elseif (isset($data['body']) && is_array($data['body'])) {
$report = $data['body'];
} elseif (isset($data[0]['body']) && is_array($data[0]['body'])) {
$report = $data[0]['body'];
}
$blocked = '';
if (isset($report['blocked-uri'])) {
$blocked = (string) $report['blocked-uri'];
} elseif (isset($report['blockedURL'])) {
$blocked = (string) $report['blockedURL'];
}
$directive_raw = '';
if (isset($report['effective-directive'])) {
$directive_raw = (string) $report['effective-directive'];
} elseif (isset($report['effectiveDirective'])) {
$directive_raw = (string) $report['effectiveDirective'];
} elseif (isset($report['violated-directive'])) {
$directive_raw = (string) $report['violated-directive'];
} elseif (isset($report['violatedDirective'])) {
$directive_raw = (string) $report['violatedDirective'];
}
$document = '';
if (isset($report['document-uri'])) {
$document = (string) $report['document-uri'];
} elseif (isset($report['documentURL'])) {
$document = (string) $report['documentURL'];
}
$origin = ASH_CSP_Normalizer::origin_from_url($blocked);
$directive = ASH_CSP_Normalizer::classify($directive_raw);
if ($origin === '') {
status_header(204);
exit;
}
ASH_CSP_Repository::upsert(array(
'origin' => $origin,
'directive' => $directive,
'resource_type' => $directive_raw !== '' ? sanitize_key($directive_raw) : 'report',
'method' => 'report-only',
'page' => ASH_CSP_Normalizer::page_path($document),
'detected_from' => '',
));
status_header(204);
exit;
}
/**
* Accept runtime observer batches. Origins only, no full URLs.
*
* @return void
*/
public function ajax_runtime() {
if (!self::is_observing() || !$this->allow_ingest()) {
status_header(204);
exit;
}
$state = self::get_state();
$token = isset($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : '';
if ($state['token'] === '' || !hash_equals($state['token'], $token)) {
status_header(403);
exit;
}
$items = isset($_POST['items']) ? wp_unslash($_POST['items']) : array();
if (is_string($items)) {
$decoded = json_decode($items, true);
$items = is_array($decoded) ? $decoded : array();
}
if (!is_array($items)) {
status_header(204);
exit;
}
$page = isset($_POST['page']) ? ASH_CSP_Normalizer::page_path(wp_unslash($_POST['page'])) : '/';
$items = array_slice($items, 0, 20);
foreach ($items as $item) {
if (!is_array($item) || empty($item['origin'])) {
continue;
}
$origin = ASH_CSP_Normalizer::origin_from_url($item['origin']);
$type = isset($item['type']) ? sanitize_key($item['type']) : '';
$directive = ASH_CSP_Normalizer::classify($type);
if ($origin === '') {
continue;
}
ASH_CSP_Repository::upsert(array(
'origin' => $origin,
'directive' => $directive,
'resource_type' => $type,
'method' => 'runtime',
'page' => $page,
'detected_from' => '',
));
}
status_header(204);
exit;
}
/**
* Render the assistant card above the manual CSP editor.
*
* @return void
*/
public static function render_admin_card() {
$payload = self::instance()->payload();
$strings = self::ui_strings();
$durations = array(
'15min' => $strings['duration15'],
'1hour' => $strings['duration1h'],
'6hours' => $strings['duration6h'],
'24hours' => $strings['duration24h'],
'manual' => $strings['durationManual'],
);
?>
0