Skip to content

feat: route AI requests through Drupal AI module#43

Open
jjroelofs wants to merge 8 commits into1.xfrom
feature/ai-module-proxy
Open

feat: route AI requests through Drupal AI module#43
jjroelofs wants to merge 8 commits into1.xfrom
feature/ai-module-proxy

Conversation

@jjroelofs
Copy link
Copy Markdown
Contributor

Summary

  • Add AiChatController that proxies CKEditor AI Agent requests through the ai module's AiProviderPluginManager, eliminating API key exposure in the browser
  • Update getDynamicPluginConfig() and getCkEditorConfig() to set endpointUrl with engine dxai instead of passing apiKey when the ai module is available
  • Add ai and ai_provider_dxpr as module and composer dependencies

Architecture: Frontend → Drupal Controller → ai module → ai_provider_dxpr → Kavya API

Follows the same pattern as dxpr/dxpr_builder#4061.

Closes #42

Files changed

File Change
src/Controller/AiChatController.php New — proxy endpoint based on dxpr_builder's controller
ckeditor_ai_agent.routing.yml Add /api/ckeditor-ai-agent/ai/chat route
ckeditor_ai_agent.info.yml Add ai and ai_provider_dxpr dependencies
composer.json Add drupal/ai and drupal/ai_provider_dxpr
src/Plugin/CKEditor5Plugin/AiAgent.php Pass endpointUrl + engine dxai instead of apiKey
src/AiAgentConfigurationManager.php Same change for getCkEditorConfig()
ckeditor_ai_agent.services.yml Add module_handler and url_generator to config manager

Test plan

  • Install ai and ai_provider_dxpr modules
  • Configure DXPR provider key
  • Open any CKEditor 5 editor with AI Agent enabled
  • Verify AI generation works through the proxy endpoint
  • Verify API key is NOT exposed in browser (check drupalSettings / editor config)
  • Verify streaming responses work correctly
  • Verify fallback still works when ai module is not installed (direct API key mode)

Add AiChatController that proxies CKEditor AI Agent requests through the
ai module's AiProviderPluginManager. When the ai module is available,
getDynamicPluginConfig() and getCkEditorConfig() now pass endpointUrl
with engine 'dxai' instead of exposing the apiKey to the browser.

Closes #42
@jjroelofs
Copy link
Copy Markdown
Contributor Author

PR Review: feat: route AI requests through Drupal AI module

Overall this is a solid architectural improvement — moving API keys server-side via a proxy controller is the right pattern. However, there are several concerns ranging from security issues to architectural decisions.


Critical Issues

1. Security: Route permission is too permissive
ckeditor_ai_agent.routing.yml — The route uses _permission: 'access content', which is granted to anonymous users on many Drupal sites. This means any unauthenticated visitor could hit the AI proxy endpoint and consume API credits. This should at minimum require a dedicated permission like 'use ckeditor ai agent', or even better, add a CSRF token check.

2. Security: No CSRF protection
The POST endpoint at /api/ckeditor-ai-agent/ai/chat has no CSRF token validation. Drupal's REST/JSON:API routes typically use _csrf_request_header_token or similar. Without it, a malicious page could make cross-origin POST requests to this endpoint if the user has a session. Add:

requirements:
  _permission: 'use ckeditor ai agent'
  _csrf_request_header_token: 'TRUE'

3. Security: No input validation/sanitization
In AiChatController::chat(), request body fields like $data->prediction, $data->providers, $data->allowed_html_tags, etc. are passed through to the provider with minimal validation. An attacker could inject arbitrary configuration. At minimum, validate/whitelist expected values.


Architectural Concerns

4. Hard dependency on ai and ai_provider_dxpr is wrong
The info.yml and composer.json make these hard dependencies, but getCkEditorConfig() and getDynamicPluginConfig() both have if ($useAiModule) fallback logic for when the module is not installed. This is contradictory — if they're hard dependencies, the fallback code is dead. If you want the fallback, these should be soft dependencies (listed under suggests in composer and removed from info.yml dependencies). The PR description itself says "Verify fallback still works when ai module is not installed."

5. Controller hardcodes 'dxpr' provider

$provider = $this->aiProviderManager->createInstance('dxpr');

This tightly couples the controller to a single AI provider. The ai module's architecture is designed for provider abstraction. Consider using the configured default provider or accepting the provider ID as configuration, making this more reusable.

6. Duplicate logic in two places
Both AiAgentConfigurationManager::getCkEditorConfig() and AiAgent::getDynamicPluginConfig() contain near-identical AI module detection and URL generation logic. This violates DRY. The plugin should ideally delegate to the configuration manager, or the shared logic should live in one place.


Code Quality Issues

7. No rate limiting
The proxy endpoint has no rate limiting. A user with access content permission could flood the endpoint with requests, burning through API credits.

8. ob_flush() may fail with output buffering disabled
In AiChatController.php:153-154, ob_flush() will emit a warning if output buffering isn't active. Wrap it:

if (ob_get_level() > 0) {
    ob_flush();
}
flush();

9. Model default 'kavya-m1' is hardcoded

$model = $data->model ?? 'kavya-m1';

This default should come from configuration, not be hardcoded in the controller.


Minor/Nit

  • $useAiModule naming: Drupal PHP coding standards use snake_case for variables ($use_ai_module).
  • The $config['jsonrpc'] = FALSE; line lacks a comment explaining why JSON-RPC is disabled — the inline comment says "OpenAI client library compatibility" but this deserves more context.

Summary

Category Issue Severity
Security access content permission too broad Critical
Security No CSRF protection on POST endpoint Critical
Security No input validation on proxy passthrough High
Architecture Hard deps contradict fallback logic High
Architecture Provider hardcoded to 'dxpr' Medium
Architecture Duplicate config logic in two classes Medium
Reliability ob_flush() warning when no buffering Low
Code quality Hardcoded model default Low
Code quality Variable naming conventions Low

Recommendation: Do not merge until the security issues (permission, CSRF, input validation) and the hard-vs-soft dependency contradiction are resolved. The core approach is sound and well-structured, but it needs hardening before it's production-ready.

- Add dedicated 'use ckeditor ai agent' permission instead of 'access content'
- Add CSRF request header token check on proxy route
- Validate message roles, types, and model format in AiChatController
- Type-check all passthrough fields (providers, allowed_html_tags, etc.)
- Make ai/ai_provider_dxpr soft dependencies (suggest in composer, removed
  from info.yml) to match the existing fallback logic
- Guard ob_flush() calls with ob_get_level() check
- Fix variable naming to Drupal snake_case convention
@jjroelofs
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! Pushed fixes in b139a39. Here's what I addressed and where I'm pushing back:

Addressed

1. Permission too permissive — Fixed. Added a dedicated use ckeditor ai agent permission and switched the route to use it.

2. CSRF protection — Fixed. Added _csrf_request_header_token: 'TRUE' to the route. CKEditor's JS fetch requests already send the X-Drupal-Ajax-Token header.

3. Input validation — Fixed. The controller now:

  • Validates messages is an array
  • Validates each message has role (string, whitelisted to system/user/assistant) and content (string)
  • Validates model against [a-zA-Z0-9._-]+ pattern
  • Type-checks all passthrough fields (providers must be array of strings, allowed_html_tags/allowed_html_classes must be strings, prediction/response_format must be objects)

4. Hard deps → soft deps — Fixed. Removed ai:ai and ai_provider_dxpr:ai_provider_dxpr from info.yml dependencies. Moved to suggest in composer.json. The fallback code path (direct API key mode) is now properly reachable.

8. ob_flush() guard — Fixed. Wrapped with if (ob_get_level() > 0).

10. Variable naming — Fixed. $useAiModule$use_ai_module throughout.

Pushing back

5. Hardcoded 'dxpr' provider — This is intentional. The entire architecture of this PR (and dxpr_builder#4061) is specifically to route through the DXPR provider → Kavya API. The ai module's provider abstraction is useful when you want the site admin to pick a provider, but here we're building a specific product integration. The dxpr_builder controller does the exact same thing. Making this configurable would add complexity with no use case.

6. Duplicate logic in two classes — The shared logic is 3 lines (module check + URL generation + engine assignment). getDynamicPluginConfig() operates on per-editor config with fallbacks; getCkEditorConfig() operates on global config. They're different code paths that happen to share the same AI-module-detection snippet. Extracting a shared method would couple these two classes more tightly for negligible DRY benefit. Happy to revisit if the proxy setup logic grows more complex.

7. Rate limiting — This is a cross-cutting infrastructure concern, not specific to this endpoint. Drupal has contrib modules for rate limiting (flood_control, etc.), and it can be handled at the reverse proxy level. Adding endpoint-specific rate limiting here would be out of scope and inconsistent with how other Drupal AJAX endpoints work (including dxpr_builder's own AI endpoint, which also has no rate limiting).

9. Hardcoded model default 'kavya-m1' — This is just a safety-net fallback. In practice, the model always comes from the JS plugin's config (which gets it from getDynamicPluginConfig()). The default only triggers if the JS somehow sends a request without a model field. kavya-m1 is the correct default for the DXPR provider. Making this configurable adds config surface for a code path that shouldn't be hit.

11. jsonrpc comment — The inline comment already says "Disable JSON-RPC agent/status messages — they are not compatible with the openai-php/client library used by the JS plugin." I've expanded the comment slightly in the new commit to clarify it's specifically about agent/status messages.

Use getDefaultProviderForOperationType('chat') to resolve whichever
provider the site admin has configured. Falls back to the default
model_id from ai module config instead of hardcoding 'kavya-m1'.
DXPR-specific passthrough fields are safely ignored by other providers.
@jjroelofs
Copy link
Copy Markdown
Contributor Author

Update (e5679ad): Corrected my pushback on issue #5 — the controller now uses the default configured provider instead of hardcoding 'dxpr':

$default = $this->aiProviderManager->getDefaultProviderForOperationType('chat');
$provider = $this->aiProviderManager->createInstance($default['provider_id']);

This means any provider configured in /admin/config/ai/settings (DXPR, OpenAI, Anthropic, Ollama, etc.) will work. The model default also comes from the ai module config rather than being hardcoded. Returns a clear 503 error with instructions if no default provider is configured.

The DXPR-specific passthrough fields (providers, allowed_html_tags, etc.) are kept since they're safely ignored by non-DXPR providers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Route AI requests through Drupal AI module (security: hide API keys from browser)

1 participant