Files
WP-LIT-MCP/src/Admin/AccessTab.php
T
janniclas.loosenandClaude Opus 5.5 b24eb6c3e9 fix(security): no direct HTTP access to plugin PHP files (audit L2, cheap part)
Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:32:12 +02:00

246 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\Access;
defined('ABSPATH') || exit;
/**
* Tab "Zugriff": roles allowed to use LIT MCP and the active OAuth connections
* with revocation (spec §8.4).
*/
final class AccessTab implements AdminTab
{
public const GROUP = 'lit_mcp_access';
public const REVOKE_ACTION = 'lit_mcp_revoke';
private AdminPostAction $action;
public function __construct(
private Access $access,
private ConnectionRepository $connections,
?callable $terminate = null
) {
$this->action = new AdminPostAction($terminate);
}
public function slug(): string
{
return 'zugriff';
}
public function label(): string
{
return __('Zugriff', 'wp-lit-mcp');
}
public function registerSettings(): void
{
register_setting(self::GROUP, Access::OPTION, [
'type' => 'array',
'sanitize_callback' => [self::class, 'sanitizeRoles'],
'default' => ['administrator'],
]);
}
public function registerActions(): void
{
add_action('admin_post_' . self::REVOKE_ACTION, [$this, 'handleRevoke']);
}
/**
* Keeps only existing role slugs. Runs twice on the very first save (update_option
* falls through to add_option, which sanitizes again), so each warning is added
* only once. The settings page itself needs manage_options, not lit_mcp_use, so
* removing the current user's own role is allowed – but warned about.
*
* @return list<string>
*/
public static function sanitizeRoles(mixed $input): array
{
$existing = array_keys((array) wp_roles()->roles);
$roles = array_values(array_unique(array_intersect(
array_filter(is_array($input) ? $input : [], 'is_string'),
$existing
)));
if ($roles === []) {
self::warnOnce(
'lit_mcp_no_roles',
__('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp')
);
} elseif (self::currentUserLosesAccess($roles)) {
self::warnOnce(
'lit_mcp_own_role',
__(
'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.',
'wp-lit-mcp'
)
);
}
return $roles;
}
public function render(): void
{
$allowed = $this->access->allowedRoles();
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
echo '<form method="post" action="options.php">';
settings_fields(self::GROUP);
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
printf(
'<table class="form-table" role="presentation"><tbody><tr><th scope="row">%1$s</th><td>'
. '<fieldset><legend class="screen-reader-text"><span>%1$s</span></legend>',
esc_html($legend)
);
foreach (wp_roles()->get_names() as $role => $name) {
printf(
'<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
esc_attr(Access::OPTION),
esc_attr((string) $role),
checked(in_array($role, $allowed, true), true, false),
esc_html(translate_user_role((string) $name))
);
}
echo '</fieldset><p class="description">'
. esc_html__('Nur Benutzer dieser Rollen können Claude verbinden.', 'wp-lit-mcp') . ' '
. esc_html__('Innerhalb der Tools gelten zusätzlich ihre normalen WordPress-Rechte.', 'wp-lit-mcp')
. '</p></td></tr></tbody></table>';
submit_button();
echo '</form>';
$this->renderConnections();
}
public function handleRevoke(): void
{
$this->action->guard();
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified right below.
$uuid = isset($_POST['uuid']) && is_string($_POST['uuid'])
? sanitize_text_field(wp_unslash($_POST['uuid']))
: '';
$userId = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Missing
check_admin_referer(self::REVOKE_ACTION . '_' . $uuid);
$revoked = $this->connections->revoke($userId, $uuid);
$this->action->redirect($this->slug(), $revoked ? 'revoked' : 'revoke_failed');
}
private static function warnOnce(string $code, string $message): void
{
foreach ((array) get_settings_errors(Access::OPTION) as $error) {
if (is_array($error) && ($error['code'] ?? '') === $code) {
return;
}
}
add_settings_error(Access::OPTION, $code, $message, 'warning');
}
/** @param list<string> $roles */
private static function currentUserLosesAccess(array $roles): bool
{
// Multisite super admins pass every capability check regardless of role.
if (is_multisite() && is_super_admin()) {
return false;
}
$user = wp_get_current_user();
return array_intersect((array) $user->roles, $roles) === [];
}
private function renderConnections(): void
{
echo '<h2>' . esc_html__('Verbindungen', 'wp-lit-mcp') . '</h2>';
$connections = $this->connections->all();
printf(
'<table class="widefat striped lit-mcp-connections"><thead><tr>'
. '<th scope="col" class="column-user">%s</th><th scope="col" class="column-client">%s</th>'
. '<th scope="col" class="column-created">%s</th><th scope="col" class="column-last-used">%s</th>'
. '<th scope="col" class="column-actions"><span class="screen-reader-text">%s</span></th>'
. '</tr></thead><tbody>',
esc_html__('Benutzer', 'wp-lit-mcp'),
esc_html__('Client', 'wp-lit-mcp'),
esc_html__('Verbunden am', 'wp-lit-mcp'),
esc_html__('Zuletzt genutzt', 'wp-lit-mcp'),
esc_html__('Aktionen', 'wp-lit-mcp')
);
if ($connections === []) {
printf(
'<tr class="no-items"><td class="colspanchange" colspan="5">%s %s <a href="%s">%s</a></td></tr>',
esc_html__('Noch keine Verbindung.', 'wp-lit-mcp'),
esc_html__('So verbindest du Claude:', 'wp-lit-mcp'),
esc_url(SettingsPage::url('verbindung')),
esc_html__('Tab „Verbindung“', 'wp-lit-mcp')
);
}
foreach ($connections as $connection) {
$this->renderConnectionRow($connection);
}
echo '</tbody></table>';
if ($connections !== []) {
echo '<p class="description">' . esc_html__(
'„Zuletzt genutzt“ wird wie bei Anwendungspasswörtern höchstens einmal am Tag aktualisiert.',
'wp-lit-mcp'
) . '</p>';
}
}
/**
* @param array{user_id: int, user_login: string, uuid: string, name: string,
* app_id: string, created: int, last_used: ?int} $connection
*/
private function renderConnectionRow(array $connection): void
{
$dateFormat = (string) get_option('date_format', 'd.m.Y');
$timeFormat = (string) get_option('time_format', 'H:i');
echo '<tr>';
echo '<td class="column-user">' . esc_html($connection['user_login']) . '</td>';
printf(
'<td class="column-client"><strong>%s</strong><br><span class="lit-mcp-muted">%s</span></td>',
esc_html($connection['name']),
esc_html($connection['app_id'])
);
printf(
'<td class="column-created">%s</td>',
esc_html((string) wp_date($dateFormat . ' ' . $timeFormat, $connection['created']))
);
$lastUsed = $connection['last_used'] === null
? '–'
: (string) wp_date($dateFormat, $connection['last_used']);
echo '<td class="column-last-used">' . esc_html($lastUsed) . '</td>';
echo '<td class="column-actions">';
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::REVOKE_ACTION));
printf('<input type="hidden" name="user_id" value="%s">', esc_attr((string) $connection['user_id']));
printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
$confirm = esc_js(sprintf(
/* translators: %s: client name */
__('Verbindung „%s“ widerrufen? Claude muss sich danach neu anmelden.', 'wp-lit-mcp'),
$connection['name']
));
submit_button(
__('Widerrufen', 'wp-lit-mcp'),
'delete small',
'submit',
false,
['onclick' => "return window.confirm('" . $confirm . "');"]
);
echo '</form></td></tr>';
}
}