Compare commits

...

1 Commits

Author SHA1 Message Date
Evan Rusackas
4ae0bc9ade feat(extensions): add security trust configuration and signature verification
Implements a comprehensive security system for Superset extensions:

Backend:
- Add EXTENSIONS_TRUST_CONFIG to superset_config.py for admin control
- Create ExtensionSecurityManager for trust validation and signature verification
- Support Ed25519 signatures for extension manifests
- Integrate trust validation into extension loading pipeline

CLI:
- Add `generate-keys` command for creating Ed25519 signing keypairs
- Add `sign` command and `--sign` option to `bundle` for manifest signing

Frontend:
- Add WASM support to webpack config for QuickJS sandbox
- Update Extension interface with trust-related fields
- ExtensionsManager now uses backend-validated trust levels

Documentation:
- Add Administrator Configuration guide for trust settings
- Add Extension Signing guide for developers
- Update security.md and sandbox.md with cross-references
- Add Security subcategory to sidebar

Tests:
- Add 21 unit tests for trust validation and signature verification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 23:43:07 -08:00
31 changed files with 7800 additions and 62 deletions

View File

@@ -0,0 +1,248 @@
---
title: Administrator Configuration
sidebar_position: 12
---
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Extension Administrator Configuration
This guide covers how to configure extension security for production deployments. As an administrator, you control which extensions can run and at what trust level.
## Trust Configuration
Configure extension trust in `superset_config.py`:
```python
EXTENSIONS_TRUST_CONFIG = {
# Extensions that can run with full privileges ('core' trust level)
"trusted_extensions": [
"official-parquet-export",
"enterprise-sso-plugin",
],
# Allow any extension to run as 'core' without signature verification
# WARNING: NEVER enable in production - development use only!
"allow_unsigned_core": False,
# Default sandbox for extensions without explicit trust configuration
# Options: 'core', 'iframe', 'worker', 'wasm'
"default_trust_level": "iframe",
# Require valid signatures for extensions requesting 'core' trust
# Recommended for production deployments
"require_core_signatures": True,
# Public keys for verified publishers (file paths or PEM strings)
"trusted_signers": [
"/etc/superset/keys/apache-official.pub",
"/etc/superset/keys/enterprise-team.pub",
],
}
```
## Configuration Options
### `trusted_extensions`
A list of extension IDs that are allowed to run as `core` trust level without signature verification. Use this for extensions you've reviewed and trust completely.
```python
"trusted_extensions": [
"my-company-plugin",
"approved-community-extension",
],
```
### `allow_unsigned_core`
When `True`, allows any extension to run as `core` trust level regardless of signatures or trusted list. **Never enable this in production** - it's intended only for development environments.
```python
# Development only!
"allow_unsigned_core": True,
```
### `default_trust_level`
The trust level assigned to extensions that don't specify one in their manifest. The safest option is `iframe`, which provides browser-enforced isolation.
| Level | Description |
|-------|-------------|
| `iframe` | Browser-sandboxed iframe with controlled API access (recommended default) |
| `worker` | Web Worker sandbox for command-only extensions |
| `wasm` | WASM sandbox with no DOM access (most restrictive) |
| `core` | Full access to main context (not recommended as default) |
```python
"default_trust_level": "iframe",
```
### `require_core_signatures`
When `True`, extensions requesting `core` trust level must have a valid signature from a trusted signer. Extensions without valid signatures are downgraded to `default_trust_level`.
```python
"require_core_signatures": True,
```
### `trusted_signers`
A list of public keys authorized to sign extensions. Keys can be specified as file paths or inline PEM strings.
```python
"trusted_signers": [
# File path to public key
"/etc/superset/keys/publisher.pub",
# Inline PEM string
"""-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----""",
],
```
## Signature Verification
### How It Works
1. Extension developers generate a signing keypair using the CLI
2. They sign their extension's manifest during the build process
3. The signed bundle includes `manifest.sig` alongside `manifest.json`
4. When Superset loads the extension, it verifies the signature against `trusted_signers`
5. If verification passes, the extension can run at its requested trust level
### Configuring Trusted Signers
1. Obtain the publisher's public key file (`.pub` extension)
2. Place it in a secure location on your server (e.g., `/etc/superset/keys/`)
3. Add the path to `trusted_signers` in your configuration
```python
EXTENSIONS_TRUST_CONFIG = {
"trusted_signers": [
"/etc/superset/keys/acme-corp.pub",
],
"require_core_signatures": True,
}
```
### Verifying a Key Fingerprint
Before adding a public key to your trusted signers, verify its fingerprint with the publisher:
```bash
# On the publisher's machine
superset-extensions generate-keys --output my-key.pem
# Output: Fingerprint: MCowBQYDK2Vw...
```
Compare this fingerprint with what you receive to ensure authenticity.
## Security Recommendations
### Production Deployments
1. **Set `require_core_signatures: True`** - Ensures core extensions are verified
2. **Set `allow_unsigned_core: False`** - Never allow unsigned core extensions
3. **Use `iframe` as default** - Provides strong browser isolation
4. **Limit `trusted_extensions`** - Only add extensions you've thoroughly reviewed
5. **Secure key storage** - Store public keys in protected directories
### Development Environments
For local development, you may relax some restrictions:
```python
# Development configuration
EXTENSIONS_TRUST_CONFIG = {
"trusted_extensions": [],
"allow_unsigned_core": True, # OK for development
"default_trust_level": "core", # Easier debugging
"require_core_signatures": False,
"trusted_signers": [],
}
```
## Extension Installation
### From Trusted Sources
1. Download the `.supx` bundle from a trusted source
2. Verify any checksums or signatures provided by the publisher
3. Place the bundle in your `EXTENSIONS_PATH` directory
4. If the extension requires `core` trust, add it to `trusted_extensions` or configure signature verification
### From Community Registry
Extensions from the community registry should be treated as semi-trusted at best. Consider:
1. Using `iframe` sandbox for community extensions
2. Reviewing the extension's source code before installation
3. Testing in a staging environment first
## Monitoring Extensions
### Logging
Extension trust decisions are logged at the INFO level:
```
INFO: Extension my-extension granted core trust (trusted + valid signature)
WARNING: Extension unknown-ext trust downgraded from core to iframe: Extension not in trusted list
```
Review these logs to monitor extension behavior and identify potential issues.
### Trust Downgrades
If an extension's trust is downgraded, you'll see a warning in the logs. Common reasons:
| Reason | Meaning |
|--------|---------|
| "Extension not in trusted list" | Extension requests core but isn't in `trusted_extensions` |
| "Core trust requires a valid signature" | `require_core_signatures` is enabled but signature is missing |
| "Signature verification failed" | Signature doesn't match any trusted signer |
## Troubleshooting
### Extension Not Loading as Core
1. Check if the extension ID is in `trusted_extensions`
2. If using signatures, verify the public key is in `trusted_signers`
3. Check logs for trust downgrade messages
4. Verify the extension bundle contains `manifest.sig`
### Signature Verification Failing
1. Ensure the public key file is readable by the Superset process
2. Verify the key is in PEM format with correct Ed25519 type
3. Check that the manifest wasn't modified after signing
4. Confirm the signature was created with the matching private key
### Permission Denied Errors
Sandboxed extensions may encounter permission errors if:
1. The extension's declared permissions don't match its API calls
2. The sandbox is blocking access correctly (working as intended)
3. The extension was downgraded to a more restrictive sandbox
Check the extension's `sandbox.permissions` configuration against its actual needs.

View File

@@ -0,0 +1,416 @@
---
title: Extension Sandboxing
sidebar_position: 10
---
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Extension Sandboxing
Superset provides a tiered sandbox architecture for running extensions with varying levels of trust and isolation. This system balances security with functionality, allowing extensions to be safely executed based on their trust level and requirements.
## Overview
The sandbox system supports three tiers of trust:
| Tier | Trust Level | Isolation | Use Case |
|------|-------------|-----------|----------|
| **Tier 1** | `core` | None (main context) | Official/signed extensions |
| **Tier 2** | `iframe` | Browser sandbox | Community UI extensions |
| **Tier 3** | `wasm` | WASM sandbox | Logic-only extensions |
## Trust Levels
### Tier 1: Core (Trusted)
Core extensions run in the main JavaScript context with full access to Superset APIs, DOM, and browser capabilities. This is the same behavior as legacy extensions.
**Requirements:**
- Must be in the trusted extensions list, OR
- `allowUnsignedCore` configuration must be enabled
**Use cases:**
- Official Apache Superset extensions
- Enterprise-verified plugins
- Extensions from trusted sources
```json
{
"id": "official-extension",
"sandbox": {
"trustLevel": "core",
"requiresSignature": true
}
}
```
### Tier 2: Iframe (Semi-Trusted)
Iframe-sandboxed extensions run in isolated browser sandboxes with controlled API access via postMessage. This provides strong browser-enforced isolation while still allowing full UI rendering.
**Security features:**
- Browser-enforced same-origin isolation
- Content Security Policy (CSP) restrictions
- Permission-based API access
- No access to parent window's cookies, localStorage, or DOM
**Use cases:**
- Community-contributed extensions
- Third-party plugins
- Extensions that render custom UI
```json
{
"id": "community-extension",
"sandbox": {
"trustLevel": "iframe",
"permissions": ["sqllab:read", "notification:show"],
"csp": {
"connectSrc": ["https://api.example.com"]
}
}
}
```
### Tier 3: WASM (Untrusted)
WASM-sandboxed extensions run in a QuickJS WebAssembly sandbox with no DOM access. Only explicitly injected APIs are available. This provides the highest level of isolation.
**Security features:**
- Complete isolation from browser APIs
- Memory limits to prevent DoS
- Execution time limits
- No network or DOM access
**Use cases:**
- Custom data transformations
- Calculated fields and formatters
- Data validation rules
- Custom aggregation functions
```json
{
"id": "formatter-extension",
"sandbox": {
"trustLevel": "wasm",
"resourceLimits": {
"maxMemory": 10485760,
"maxExecutionTime": 5000
}
}
}
```
## Permissions
Sandboxed extensions (Tier 2 and 3) must declare the permissions they need. Permissions follow a least-privilege model.
### Available Permissions
| Permission | Description |
|------------|-------------|
| `api:read` | Read-only access to Superset APIs |
| `api:write` | Write access to Superset APIs |
| `sqllab:read` | Read SQL Lab state (queries, results) |
| `sqllab:execute` | Execute SQL queries |
| `dashboard:read` | Read dashboard data |
| `dashboard:write` | Modify dashboards |
| `chart:read` | Read chart data |
| `chart:write` | Modify charts |
| `user:read` | Read current user info |
| `notification:show` | Show notifications to user |
| `modal:open` | Open modal dialogs |
| `navigation:redirect` | Navigate to other pages |
| `clipboard:write` | Write to clipboard |
| `download:file` | Trigger file downloads |
### Example Permission Declaration
```json
{
"sandbox": {
"trustLevel": "iframe",
"permissions": [
"sqllab:read",
"notification:show",
"download:file"
]
}
}
```
## Sandboxed Extension API
Extensions running in iframe sandboxes have access to a controlled API through the `window.superset` object.
### SQL Lab API
```typescript
// Get the current SQL Lab tab (requires sqllab:read)
const tab = await window.superset.sqlLab.getCurrentTab();
// Get query results (requires sqllab:read)
const results = await window.superset.sqlLab.getQueryResults(queryId);
```
### Dashboard API
```typescript
// Get dashboard context (requires dashboard:read)
const context = await window.superset.dashboard.getContext();
// Get dashboard filters (requires dashboard:read)
const filters = await window.superset.dashboard.getFilters();
```
### Chart API
```typescript
// Get chart data (requires chart:read)
const chartData = await window.superset.chart.getData(chartId);
```
### User API
```typescript
// Get current user (requires user:read)
const user = await window.superset.user.getCurrentUser();
```
### UI API
```typescript
// Show notification (requires notification:show)
window.superset.ui.showNotification('Success!', 'success');
// Open modal (requires modal:open)
const result = await window.superset.ui.openModal({
title: 'Confirm',
content: 'Are you sure?',
type: 'confirm'
});
// Navigate (requires navigation:redirect)
window.superset.ui.navigateTo('/dashboard/1');
```
### Utility API
```typescript
// Copy to clipboard (requires clipboard:write)
await window.superset.utils.copyToClipboard('text');
// Download file (requires download:file)
window.superset.utils.downloadFile(blob, 'filename.csv');
// Get CSRF token (no permission required)
const token = await window.superset.utils.getCSRFToken();
```
### Event Subscriptions
```typescript
// Subscribe to events
const unsubscribe = window.superset.on('dashboard:filterChange', (filters) => {
console.log('Filters changed:', filters);
});
// Later, unsubscribe
unsubscribe();
```
## Content Security Policy
Iframe-sandboxed extensions can customize their Content Security Policy through the `csp` configuration:
```json
{
"sandbox": {
"trustLevel": "iframe",
"csp": {
"defaultSrc": ["'none'"],
"scriptSrc": ["'unsafe-inline'"],
"styleSrc": ["'unsafe-inline'"],
"imgSrc": ["data:", "blob:", "https://cdn.example.com"],
"connectSrc": ["https://api.example.com"],
"fontSrc": ["data:"]
}
}
}
```
### Default CSP
By default, iframe sandboxes use a restrictive CSP:
```
default-src 'none';
script-src 'unsafe-inline';
style-src 'unsafe-inline';
img-src data: blob:;
font-src data:;
connect-src 'none';
frame-src 'none';
```
## WASM Resource Limits
WASM-sandboxed extensions can configure resource limits:
```json
{
"sandbox": {
"trustLevel": "wasm",
"resourceLimits": {
"maxMemory": 10485760, // 10MB max memory
"maxExecutionTime": 5000, // 5 second timeout
"maxStackSize": 1000 // Max call stack depth
}
}
}
```
### Defaults
- **maxMemory**: 10MB
- **maxExecutionTime**: 5000ms (5 seconds)
- **maxStackSize**: 1000 calls
## Migration Guide
### Migrating from Legacy Extensions
Existing extensions that don't specify a `sandbox` configuration will continue to run as `core` extensions for backward compatibility. To migrate to a sandboxed model:
1. **Assess your extension's requirements**:
- Does it need to render UI? Use `iframe`
- Is it logic-only (formatters, validators)? Use `wasm`
- Does it need full access? Keep as `core` (requires trust)
2. **Add sandbox configuration to extension.json**:
```json
{
"sandbox": {
"trustLevel": "iframe",
"permissions": ["sqllab:read"]
}
}
```
3. **Update your code to use the sandboxed API**:
Before (core extension):
```typescript
import { sqlLab } from '@apache-superset/core';
const tab = sqlLab.getCurrentTab();
```
After (sandboxed extension):
```typescript
const tab = await window.superset.sqlLab.getCurrentTab();
```
4. **Test thoroughly** to ensure all functionality works within the sandbox
## Security Comparison
| Aspect | Core | Iframe | WASM |
|--------|------|--------|------|
| DOM Access | Full | Own iframe only | None |
| Network | Full | Restricted (CSP) | None |
| Cookies | Full | None | None |
| localStorage | Full | None | None |
| Superset APIs | Full | Controlled bridge | Injected only |
| Performance | Native | Near-native | ~40% slower |
| React rendering | Full | Own instance | Via descriptors |
## Administrator Configuration
Administrators can configure trust settings for their Superset deployment:
```python
# In superset_config.py
EXTENSIONS_TRUST_CONFIG = {
# Extensions allowed to run as 'core'
"trusted_extensions": [
"official-extension-1",
"enterprise-plugin",
],
# Allow unsigned extensions to run as core (not recommended for production)
"allow_unsigned_core": False,
# Default trust level for extensions without sandbox config
"default_trust_level": "iframe",
}
```
## Best Practices
1. **Request minimal permissions** - Only request the permissions your extension actually needs
2. **Prefer iframe over core** - Unless your extension requires deep integration, use iframe sandboxing
3. **Use WASM for pure logic** - If your extension doesn't need UI, WASM provides the best isolation
4. **Handle permission denials gracefully** - Your extension should degrade gracefully if a permission is not granted
5. **Don't store sensitive data** - Sandboxed extensions should not store sensitive user data
6. **Test in sandboxed mode** - Always test your extension in its intended sandbox environment
## Troubleshooting
### Permission Denied Errors
If you see "Permission denied" errors, verify that:
1. The permission is declared in your extension.json
2. The permission was granted by the administrator
3. You're calling the correct API method for that permission
### Timeout Errors (WASM)
If your WASM extension times out:
1. Optimize your code for faster execution
2. Request a higher `maxExecutionTime` limit
3. Break large operations into smaller chunks
### CSP Violations (Iframe)
If resources fail to load due to CSP:
1. Add the required domains to your CSP configuration
2. Ensure you're using HTTPS for external resources
3. Avoid inline scripts and styles where possible
### Core Trust Denied
If your extension is downgraded from `core` to another trust level:
1. Check if the extension ID is in the administrator's `trusted_extensions` list
2. If signature verification is required, ensure the extension is signed
3. Verify the signing key is in the administrator's `trusted_signers`
See [Extension Signing](./signing) for how to sign your extension.
## Related Documentation
- [Security Overview](./security) - Extension security fundamentals
- [Extension Signing](./signing) - How to sign extensions for core trust
- [Administrator Configuration](./admin-configuration) - Trust configuration for admins

View File

@@ -26,9 +26,44 @@ under the License.
By default, extensions are disabled and must be explicitly enabled by setting the `ENABLE_EXTENSIONS` feature flag. Built-in extensions are included as part of the Superset codebase and are held to the same security standards and review processes as the rest of the application.
For external extensions, administrators are responsible for evaluating and verifying the security of any extensions they choose to install, just as they would when installing third-party NPM or PyPI packages. At this stage, all extensions run in the same context as the host application, without additional sandboxing. This means that external extensions can impact the security and performance of a Superset environment in the same way as any other installed dependency.
## Extension Sandboxing
We plan to introduce an optional sandboxed execution model for extensions in the future (as part of an additional SIP). Until then, administrators should exercise caution and follow best practices when selecting and deploying third-party extensions. A directory of community extensions is available in the [Community Extensions](./registry) page. Note that these extensions are not vetted by the Apache Superset project—administrators must evaluate each extension before installation.
Superset provides a tiered sandbox architecture for running extensions with varying levels of trust and isolation. Extensions can declare their trust level and permissions in their manifest, and Superset will load them in the appropriate sandbox:
- **Core (Tier 1)**: Trusted extensions run in the main context with full access
- **Iframe (Tier 2)**: Semi-trusted extensions run in browser-sandboxed iframes
- **WASM (Tier 3)**: Untrusted logic runs in WebAssembly sandboxes
For detailed information about the sandbox system, see [Extension Sandboxing](./sandbox).
## Trust Model
Administrators are responsible for evaluating and verifying the security of any extensions they choose to install. Superset's sandbox system provides defense-in-depth:
1. **Core extensions** require explicit trust configuration and optionally signature verification
2. **Iframe-sandboxed extensions** are isolated by the browser's same-origin policy
3. **WASM-sandboxed extensions** have no access to browser APIs
A directory of community extensions is available in the [Community Extensions](./registry) page. Note that these extensions are not vetted by the Apache Superset project—administrators must evaluate each extension before installation.
## Extension Signing
Extensions can be cryptographically signed to verify their authenticity and integrity. This is required for extensions that need `core` trust level in production environments with signature verification enabled.
- **Developers**: See [Extension Signing](./signing) to learn how to sign your extensions
- **Administrators**: See [Administrator Configuration](./admin-configuration) to configure trusted signers
## Administrator Configuration
Superset provides extensive configuration options for controlling extension trust levels, signature verification, and security policies. Key settings include:
- **Trusted extensions list**: Extensions allowed to run as `core`
- **Signature verification**: Require valid signatures for core trust
- **Default trust level**: Sandbox level for unlisted extensions
For complete configuration details, see [Administrator Configuration](./admin-configuration).
## Security Reporting
**Any performance or security vulnerabilities introduced by external extensions should be reported directly to the extension author, not as Superset vulnerabilities.**

View File

@@ -0,0 +1,236 @@
---
title: Extension Signing
sidebar_position: 11
---
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Extension Signing
Signing your extension allows administrators to verify its authenticity and integrity. Signed extensions can run as `core` trust level in production environments where signature verification is required.
## Why Sign Extensions?
- **Trust**: Administrators can verify your extension comes from a known source
- **Integrity**: Ensures the extension hasn't been modified since you signed it
- **Core Access**: Required for extensions needing `core` trust level in secured deployments
- **Distribution**: Makes your extension suitable for enterprise environments
## Generating Signing Keys
Generate a new Ed25519 keypair for signing your extensions:
```bash
superset-extensions generate-keys --output my-signing-key.pem
```
This creates two files:
| File | Purpose | Share? |
|------|---------|--------|
| `my-signing-key.pem` | Private key for signing | **Never share!** |
| `my-signing-key.pub` | Public key for verification | Share with administrators |
**Output example:**
```
✅ Private key: my-signing-key.pem
✅ Public key: my-signing-key.pub
Fingerprint: MCowBQYDK2Vw...
⚠️ Keep the private key secure! Only share the public key with administrators.
Usage:
Sign an extension: superset-extensions bundle --sign my-signing-key.pem
Share with admins: my-signing-key.pub
```
## Signing an Extension
### During Bundle
The easiest way to sign is during the bundle step:
```bash
superset-extensions bundle --sign my-signing-key.pem
```
This builds, signs the manifest, and creates the `.supx` bundle in one command.
**Output:**
```
✅ Full build completed in dist/
✅ Manifest signed
✅ Bundle created (signed): my-extension-1.0.0.supx
```
### Signing Existing Manifest
To sign an already-built manifest:
```bash
superset-extensions sign --key my-signing-key.pem --manifest dist/manifest.json
```
This creates `dist/manifest.sig` containing the signature.
## Bundle Structure
A signed extension bundle contains:
```
my-extension-1.0.0.supx
├── manifest.json # Extension manifest
├── manifest.sig # Ed25519 signature (base64-encoded)
├── frontend/dist/ # Frontend assets
└── backend/src/ # Backend code (if applicable)
```
The signature file (`manifest.sig`) contains a base64-encoded Ed25519 signature of the manifest content.
## Distributing Your Public Key
Share your public key (`.pub` file) with administrators who want to trust your extensions:
1. **Direct sharing**: Send the `.pub` file via secure channels
2. **Documentation**: Include in your extension's README
3. **Website**: Host on your organization's website with HTTPS
Administrators will add your public key to their `EXTENSIONS_TRUST_CONFIG.trusted_signers` configuration.
### Key Fingerprint
The fingerprint helps administrators verify they have the correct key. Include it in your documentation:
```
Public Key Fingerprint: MCowBQYDK2Vw...
```
Administrators should verify this fingerprint matches when adding your key.
## Security Best Practices
### Protect Your Private Key
- **Never commit** private keys to version control
- **Use secure storage** like hardware security modules (HSM) for production keys
- **Limit access** to the private key to authorized personnel only
- **Back up securely** in case of key loss
### Key Rotation
Consider rotating keys periodically:
1. Generate a new keypair
2. Notify administrators of the new public key
3. Sign new releases with the new key
4. Keep the old key available for verifying existing releases
### Multiple Keys
For organizations, consider separate keys for:
- Development/testing releases
- Production releases
- Different product teams
## Requesting Core Trust
If your extension needs `core` trust level:
1. **Sign your extension** using the process above
2. **Document your public key** with fingerprint
3. **Explain why core is needed** in your extension documentation
4. **Provide your public key** to administrators
Administrators will then:
1. Add your public key to `trusted_signers`
2. Enable `require_core_signatures: True`
3. Your signed extension can now run as `core`
## Verification Process
When Superset loads your extension:
1. Reads `manifest.json` and `manifest.sig` from the bundle
2. Checks if the extension requests `core` trust level
3. If `require_core_signatures` is enabled, verifies the signature
4. Checks the signature against all keys in `trusted_signers`
5. If verification passes, grants the requested trust level
6. If verification fails, downgrades to `default_trust_level`
## Troubleshooting
### "Signature verification failed"
- Ensure you're using the matching private key for the public key given to admins
- Verify the manifest wasn't modified after signing
- Check that the `.sig` file was included in the bundle
### "Private key must be Ed25519"
- The signing system only supports Ed25519 keys
- Generate a new key using `superset-extensions generate-keys`
### Administrator Reports Invalid Signature
- Verify the public key file wasn't corrupted during transfer
- Confirm the fingerprint matches between your key and theirs
- Re-sign the extension and redistribute
## Technical Details
### Signature Algorithm
Extensions use **Ed25519** signatures:
- Fast signature generation and verification
- Small signature size (64 bytes)
- Strong security guarantees
- Deterministic signatures (same input always produces same output)
### Signature Format
The `manifest.sig` file contains:
```
<base64-encoded Ed25519 signature>
```
The signature is computed over the raw bytes of `manifest.json`.
### Key Format
Keys are stored in PEM format:
**Private key:**
```
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEI...
-----END PRIVATE KEY-----
```
**Public key:**
```
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----
```

View File

@@ -49,7 +49,17 @@ module.exports = {
'extensions/development',
'extensions/deployment',
'extensions/mcp',
'extensions/security',
{
type: 'category',
label: 'Security',
collapsed: true,
items: [
'extensions/security',
'extensions/sandbox',
'extensions/signing',
'extensions/admin-configuration',
],
},
'extensions/registry',
],
},

View File

@@ -0,0 +1,177 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Extension manifest signing utilities.
This module provides cryptographic signing and verification utilities
for Superset extension manifests using Ed25519 signatures.
Ed25519 was chosen for:
- Fast signature generation and verification
- Small signature size (64 bytes)
- Strong security guarantees
- Resistance to side-channel attacks
Example usage:
# Generate a new keypair
>>> private_pem, public_pem = generate_keypair()
>>> Path("signing-key.pem").write_bytes(private_pem)
>>> Path("signing-key.pub").write_bytes(public_pem)
# Sign a manifest
>>> manifest = Path("manifest.json").read_bytes()
>>> signature = sign_manifest(manifest, private_pem)
>>> Path("manifest.sig").write_bytes(signature)
# Verify a signature
>>> is_valid = verify_signature(manifest, signature, public_pem)
"""
from __future__ import annotations
import base64
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
def generate_keypair() -> tuple[bytes, bytes]:
"""Generate a new Ed25519 keypair for signing extensions.
Returns:
A tuple of (private_key_pem, public_key_pem) as bytes.
Example:
>>> private_pem, public_pem = generate_keypair()
>>> b"PRIVATE KEY" in private_pem
True
>>> b"PUBLIC KEY" in public_pem
True
"""
private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return private_pem, public_pem
def sign_manifest(manifest_bytes: bytes, private_key_pem: bytes) -> bytes:
"""Sign manifest content and return base64-encoded signature.
Args:
manifest_bytes: The raw manifest.json content to sign.
private_key_pem: The PEM-encoded Ed25519 private key.
Returns:
Base64-encoded signature bytes.
Raises:
ValueError: If the private key is invalid or not Ed25519.
Example:
>>> private_pem, _ = generate_keypair()
>>> manifest = b'{"id": "test", "name": "Test"}'
>>> signature = sign_manifest(manifest, private_pem)
>>> len(base64.b64decode(signature)) # Ed25519 signature is 64 bytes
64
"""
private_key = serialization.load_pem_private_key(
private_key_pem,
password=None,
)
if not isinstance(private_key, ed25519.Ed25519PrivateKey):
raise ValueError("Private key must be Ed25519")
signature = private_key.sign(manifest_bytes)
return base64.b64encode(signature)
def verify_signature(
manifest_bytes: bytes,
signature_b64: bytes,
public_key_pem: bytes,
) -> bool:
"""Verify a manifest signature.
Args:
manifest_bytes: The raw manifest.json content that was signed.
signature_b64: Base64-encoded Ed25519 signature.
public_key_pem: The PEM-encoded Ed25519 public key.
Returns:
True if the signature is valid, False otherwise.
Example:
>>> private_pem, public_pem = generate_keypair()
>>> manifest = b'{"id": "test", "name": "Test"}'
>>> signature = sign_manifest(manifest, private_pem)
>>> verify_signature(manifest, signature, public_pem)
True
>>> verify_signature(b"tampered", signature, public_pem)
False
"""
try:
public_key = serialization.load_pem_public_key(public_key_pem)
if not isinstance(public_key, ed25519.Ed25519PublicKey):
return False
signature = base64.b64decode(signature_b64)
public_key.verify(signature, manifest_bytes)
return True
except InvalidSignature:
return False
except Exception:
return False
def get_public_key_fingerprint(public_key_pem: bytes) -> str:
"""Get a fingerprint of a public key for display purposes.
The fingerprint is the first 16 characters of the base64-encoded
public key bytes, which is enough to identify keys visually.
Args:
public_key_pem: The PEM-encoded Ed25519 public key.
Returns:
A short fingerprint string for the key.
Example:
>>> _, public_pem = generate_keypair()
>>> fingerprint = get_public_key_fingerprint(public_pem)
>>> len(fingerprint)
16
"""
public_key = serialization.load_pem_public_key(public_key_pem)
raw_bytes = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return base64.b64encode(raw_bytes).decode("ascii")[:16]

View File

@@ -34,6 +34,27 @@ from pydantic import BaseModel, Field # noqa: I001
# =============================================================================
class SandboxConfig(BaseModel):
"""Configuration for extension sandboxing."""
trustLevel: str = Field( # noqa: N815
default="iframe",
description="Trust level: 'core', 'iframe', 'worker', or 'wasm'",
)
permissions: list[str] = Field(
default_factory=list,
description="Permissions requested by the extension",
)
csp: dict[str, list[str]] | None = Field(
default=None,
description="Content Security Policy for iframe sandboxes",
)
resourceLimits: dict[str, Any] | None = Field( # noqa: N815
default=None,
description="Resource limits for WASM sandboxes",
)
class ModuleFederationConfig(BaseModel):
"""Configuration for Webpack Module Federation."""
@@ -146,6 +167,10 @@ class ExtensionConfig(BaseExtension):
This file is authored by developers to define extension metadata.
"""
sandbox: SandboxConfig | None = Field(
default=None,
description="Sandbox configuration for secure extension execution",
)
frontend: ExtensionConfigFrontend | None = Field(
default=None,
description="Frontend configuration",
@@ -168,13 +193,17 @@ class ManifestFrontend(BaseModel):
default_factory=ContributionConfig,
description="UI contribution points",
)
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
default_factory=ModuleFederationConfig,
description="Module Federation configuration",
moduleFederation: ModuleFederationConfig | None = Field( # noqa: N815
default=None,
description="Module Federation configuration (not used for worker sandboxes)",
)
remoteEntry: str = Field( # noqa: N815
...,
description="Path to the built remote entry file",
remoteEntry: str | None = Field( # noqa: N815
default=None,
description="Path to the built remote entry file (for iframe/core sandboxes)",
)
workerEntry: str | None = Field( # noqa: N815
default=None,
description="Path to the built worker entry file (for worker sandboxes)",
)
@@ -194,6 +223,10 @@ class Manifest(BaseExtension):
This file is generated by the build tool from extension.json.
"""
sandbox: SandboxConfig | None = Field(
default=None,
description="Sandbox configuration for secure extension execution",
)
frontend: ManifestFrontend | None = Field(
default=None,
description="Frontend manifest",

View File

@@ -28,11 +28,17 @@ from typing import Any, Callable
import click
import semver
from jinja2 import Environment, FileSystemLoader
from superset_core.extensions.signing import (
generate_keypair,
get_public_key_fingerprint,
sign_manifest,
)
from superset_core.extensions.types import (
ExtensionConfig,
Manifest,
ManifestBackend,
ManifestFrontend,
SandboxConfig,
)
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
@@ -129,7 +135,11 @@ def clean_dist_frontend(cwd: Path) -> None:
shutil.rmtree(frontend_dist)
def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
def build_manifest(
cwd: Path,
remote_entry: str | None,
worker_entry: str | None = None,
) -> Manifest:
extension_data = read_json(cwd / "extension.json")
if not extension_data:
click.secho("❌ extension.json not found.", err=True, fg="red")
@@ -137,13 +147,32 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
extension = ExtensionConfig.model_validate(extension_data)
# Determine sandbox configuration
sandbox: SandboxConfig | None = None
if extension.sandbox:
sandbox = extension.sandbox
# Build frontend manifest based on sandbox type
frontend: ManifestFrontend | None = None
if extension.frontend and remote_entry:
frontend = ManifestFrontend(
contributions=extension.frontend.contributions,
moduleFederation=extension.frontend.moduleFederation,
remoteEntry=remote_entry,
)
is_worker_sandbox = sandbox and sandbox.trustLevel == "worker"
if extension.frontend:
if is_worker_sandbox and worker_entry:
# Worker sandbox: use workerEntry, no Module Federation
frontend = ManifestFrontend(
contributions=extension.frontend.contributions,
moduleFederation=None,
remoteEntry=None,
workerEntry=worker_entry,
)
elif remote_entry:
# Standard iframe/core sandbox: use remoteEntry
frontend = ManifestFrontend(
contributions=extension.frontend.contributions,
moduleFederation=extension.frontend.moduleFederation,
remoteEntry=remote_entry,
workerEntry=None,
)
backend: ManifestBackend | None = None
if extension.backend and extension.backend.entryPoints:
@@ -155,6 +184,7 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
version=extension.version,
permissions=extension.permissions,
dependencies=extension.dependencies,
sandbox=sandbox,
frontend=frontend,
backend=backend,
)
@@ -178,24 +208,43 @@ def run_frontend_build(frontend_dir: Path) -> subprocess.CompletedProcess[str]:
)
def copy_frontend_dist(cwd: Path) -> str:
def copy_frontend_dist(
cwd: Path, is_worker_sandbox: bool = False
) -> tuple[str | None, str | None]:
"""
Copy frontend dist files and return (remote_entry, worker_entry) tuple.
For worker sandboxes, looks for worker.js instead of remoteEntry.*.js
"""
dist_dir = cwd / "dist"
frontend_dist = cwd / "frontend" / "dist"
remote_entry: str | None = None
worker_entry: str | None = None
for f in frontend_dist.rglob("*"):
if not f.is_file():
continue
if REMOTE_ENTRY_REGEX.match(f.name):
remote_entry = f.name
if f.name == "worker.js":
worker_entry = f.name
tgt = dist_dir / f.relative_to(cwd)
tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(f, tgt)
if not remote_entry:
click.secho("❌ No remote entry file found.", err=True, fg="red")
sys.exit(1)
return remote_entry
# Validate based on sandbox type
if is_worker_sandbox:
if not worker_entry:
click.secho(
"❌ No worker.js file found for worker sandbox.", err=True, fg="red"
)
sys.exit(1)
else:
if not remote_entry:
click.secho("❌ No remote entry file found.", err=True, fg="red")
sys.exit(1)
return remote_entry, worker_entry
def copy_backend_files(cwd: Path) -> None:
@@ -214,18 +263,20 @@ def copy_backend_files(cwd: Path) -> None:
shutil.copy2(f, tgt)
def rebuild_frontend(cwd: Path, frontend_dir: Path) -> str | None:
"""Clean and rebuild frontend, return the remoteEntry filename."""
def rebuild_frontend(
cwd: Path, frontend_dir: Path, is_worker_sandbox: bool = False
) -> tuple[str | None, str | None]:
"""Clean and rebuild frontend, return (remoteEntry, workerEntry) tuple."""
clean_dist_frontend(cwd)
res = run_frontend_build(frontend_dir)
if res.returncode != 0:
click.secho("❌ Frontend build failed", fg="red")
return None
return None, None
remote_entry = copy_frontend_dist(cwd)
remote_entry, worker_entry = copy_frontend_dist(cwd, is_worker_sandbox)
click.secho("✅ Frontend rebuilt", fg="green")
return remote_entry
return remote_entry, worker_entry
def rebuild_backend(cwd: Path) -> None:
@@ -267,11 +318,21 @@ def build(ctx: click.Context) -> None:
clean_dist(cwd)
# Check if this is a worker sandbox
extension_data = read_json(cwd / "extension.json")
is_worker_sandbox: bool = bool(
extension_data
and extension_data.get("sandbox", {}).get("trustLevel") == "worker"
)
# Build frontend if it exists
remote_entry = None
worker_entry = None
if frontend_dir.exists():
init_frontend_deps(frontend_dir)
remote_entry = rebuild_frontend(cwd, frontend_dir)
remote_entry, worker_entry = rebuild_frontend(
cwd, frontend_dir, is_worker_sandbox
)
# Build backend independently if it exists
if backend_dir.exists():
@@ -280,7 +341,7 @@ def build(ctx: click.Context) -> None:
rebuild_backend(cwd)
# Build manifest and write it
manifest = build_manifest(cwd, remote_entry)
manifest = build_manifest(cwd, remote_entry, worker_entry)
write_manifest(cwd, manifest)
click.secho("✅ Full build completed in dist/", fg="green")
@@ -293,8 +354,14 @@ def build(ctx: click.Context) -> None:
type=click.Path(path_type=Path, dir_okay=True, file_okay=True, writable=True),
help="Optional output path or filename for the bundle.",
)
@click.option(
"--sign",
"-s",
type=click.Path(exists=True, path_type=Path),
help="Path to private key file (PEM format) for signing the manifest.",
)
@click.pass_context
def bundle(ctx: click.Context, output: Path | None) -> None:
def bundle(ctx: click.Context, output: Path | None, sign: Path | None) -> None:
ctx.invoke(build)
cwd = Path.cwd()
@@ -307,6 +374,23 @@ def bundle(ctx: click.Context, output: Path | None) -> None:
)
sys.exit(1)
# Sign manifest if key provided
if sign:
try:
private_key = sign.read_bytes()
manifest_bytes = manifest_path.read_bytes()
signature = sign_manifest(manifest_bytes, private_key)
sig_path = dist_dir / "manifest.sig"
sig_path.write_bytes(signature)
click.secho("✅ Manifest signed", fg="green")
except ValueError as e:
click.secho(f"❌ Signing failed: {e}", err=True, fg="red")
sys.exit(1)
except Exception as e:
click.secho(f"❌ Failed to sign manifest: {e}", err=True, fg="red")
sys.exit(1)
manifest = json.loads(manifest_path.read_text())
id_ = manifest["id"]
version = manifest["version"]
@@ -329,7 +413,8 @@ def bundle(ctx: click.Context, output: Path | None) -> None:
click.secho(f"❌ Failed to create bundle: {ex}", err=True, fg="red")
sys.exit(1)
click.secho(f"✅ Bundle created: {zip_path}", fg="green")
signed_msg = " (signed)" if sign else ""
click.secho(f"✅ Bundle created{signed_msg}: {zip_path}", fg="green")
@app.command()
@@ -341,23 +426,35 @@ def dev(ctx: click.Context) -> None:
clean_dist(cwd)
# Check if this is a worker sandbox
extension_data = read_json(cwd / "extension.json")
is_worker_sandbox: bool = bool(
extension_data
and extension_data.get("sandbox", {}).get("trustLevel") == "worker"
)
# Build frontend if it exists
remote_entry = None
worker_entry = None
if frontend_dir.exists():
init_frontend_deps(frontend_dir)
remote_entry = rebuild_frontend(cwd, frontend_dir)
remote_entry, worker_entry = rebuild_frontend(
cwd, frontend_dir, is_worker_sandbox
)
# Build backend if it exists
if backend_dir.exists():
rebuild_backend(cwd)
manifest = build_manifest(cwd, remote_entry)
manifest = build_manifest(cwd, remote_entry, worker_entry)
write_manifest(cwd, manifest)
def frontend_watcher() -> None:
if frontend_dir.exists():
if (remote_entry := rebuild_frontend(cwd, frontend_dir)) is not None:
manifest = build_manifest(cwd, remote_entry)
result = rebuild_frontend(cwd, frontend_dir, is_worker_sandbox)
if result != (None, None):
remote_entry, worker_entry = result
manifest = build_manifest(cwd, remote_entry, worker_entry)
write_manifest(cwd, manifest)
def backend_watcher() -> None:
@@ -528,5 +625,101 @@ def init(
)
@app.command("generate-keys")
@click.option(
"--output",
"-o",
type=click.Path(path_type=Path),
default=Path("extension-signing-key.pem"),
help="Output path for private key (public key will be .pub)",
)
def generate_keys(output: Path) -> None:
"""Generate a new Ed25519 keypair for signing extensions.
Creates two files:
- Private key (keep secure, never share)
- Public key (.pub suffix, share with administrators)
Example:
superset-extensions generate-keys --output my-key.pem
"""
private_pem, public_pem = generate_keypair()
private_path = output
public_path = output.with_suffix(".pub")
# Check if files already exist
if private_path.exists():
if not click.confirm(f"⚠️ {private_path} already exists. Overwrite?"):
click.secho("Aborted.", fg="yellow")
sys.exit(0)
if public_path.exists():
if not click.confirm(f"⚠️ {public_path} already exists. Overwrite?"):
click.secho("Aborted.", fg="yellow")
sys.exit(0)
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
fingerprint = get_public_key_fingerprint(public_pem)
click.secho(f"✅ Private key: {private_path}", fg="green")
click.secho(f"✅ Public key: {public_path}", fg="green")
click.secho(f" Fingerprint: {fingerprint}", fg="cyan")
click.echo()
click.secho(
"⚠️ Keep the private key secure! Only share the public key with administrators.",
fg="yellow",
)
click.echo()
click.secho("Usage:", fg="cyan")
click.echo(
f" Sign an extension: superset-extensions bundle --sign {private_path}"
)
click.echo(f" Share with admins: {public_path}")
@app.command("sign")
@click.option(
"--key",
"-k",
type=click.Path(exists=True, path_type=Path),
required=True,
help="Path to private key file (PEM format)",
)
@click.option(
"--manifest",
"-m",
type=click.Path(exists=True, path_type=Path),
default=Path("dist/manifest.json"),
help="Path to manifest.json (default: dist/manifest.json)",
)
def sign_command(key: Path, manifest: Path) -> None:
"""Sign an existing manifest.json file.
This is useful for signing a manifest after the build step,
or for re-signing with a different key.
Example:
superset-extensions sign --key my-key.pem --manifest dist/manifest.json
"""
try:
private_key = key.read_bytes()
manifest_bytes = manifest.read_bytes()
signature = sign_manifest(manifest_bytes, private_key)
sig_path = manifest.with_suffix(".sig")
sig_path.write_bytes(signature)
click.secho(f"✅ Signature written to: {sig_path}", fg="green")
except ValueError as e:
click.secho(f"❌ Signing failed: {e}", err=True, fg="red")
sys.exit(1)
except Exception as e:
click.secho(f"❌ Failed to sign manifest: {e}", err=True, fg="red")
sys.exit(1)
if __name__ == "__main__":
app()

View File

@@ -27,6 +27,7 @@
import { ReactElement } from 'react';
import { Contributions } from './contributions';
import { SandboxManifest } from './sandbox';
/**
* Represents a database column with its name and data type.
@@ -239,10 +240,52 @@ export interface Extension {
id: string;
/** Human-readable name of the extension */
name: string;
/** URL or path to the extension's remote entry point */
/** URL or path to the extension's remote entry point (for iframe/core sandboxes) */
remoteEntry: string;
/**
* Sandbox configuration for secure extension execution.
*
* @remarks
* When specified, the extension will be loaded in a sandboxed environment
* based on the trust level:
* - `core`: Full access (requires trust verification)
* - `iframe`: Browser-isolated iframe sandbox
* - `worker`: Web Worker sandbox for command-only extensions
* - `wasm`: WASM-based sandbox for logic-only extensions
*/
sandbox?: SandboxManifest;
/**
* Whether the extension's signature was successfully verified.
*
* @remarks
* This is determined by the backend based on the EXTENSIONS_TRUST_CONFIG.
* - `true`: Signature verified against a trusted signer
* - `false`: Signature verification failed
* - `null`/`undefined`: No signature verification was performed
*/
signatureValid?: boolean | null;
/**
* Backend-validated trust level for this extension.
*
* @remarks
* This is the actual trust level granted by the backend after validation,
* which may differ from what the extension requested in its manifest.
* For example, an extension requesting 'core' trust may be downgraded
* to 'iframe' if it's not in the trusted extensions list.
*/
trustLevel?: 'core' | 'iframe' | 'worker' | 'wasm';
/**
* Whether the extension's trust level was downgraded from what it requested.
*/
trustDowngraded?: boolean;
/**
* Reason why the extension's trust was downgraded (if applicable).
*/
trustDowngradeReason?: string;
/** Version of the extension */
version: string;
/** URL or path to the extension's worker entry point (for worker sandboxes) */
workerEntry?: string;
}
/**

View File

@@ -29,6 +29,7 @@
* - `contributions`: Register UI contributions and customizations
* - `core`: Access fundamental Superset types and utilities
* - `extensions`: Manage extension lifecycle and metadata
* - `sandbox`: Sandbox types and APIs for secure extension execution
* - `sqlLab`: Integrate with SQL Lab functionality
*/
@@ -37,4 +38,5 @@ export * as commands from './commands';
export * as contributions from './contributions';
export * as core from './core';
export * as extensions from './extensions';
export * as sandbox from './sandbox';
export * as sqlLab from './sqlLab';

View File

@@ -0,0 +1,280 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Sandbox API types for Superset extensions.
*
* This module defines the types and interfaces for the extension sandbox system.
* Extensions use these types to declare their security requirements and access
* the sandboxed API when running in iframe or WASM sandboxes.
*/
/**
* Trust levels for extension sandboxing.
*
* @remarks
* The trust level determines how an extension is loaded and what APIs it can access:
*
* - `core`: Full access, runs in main context (requires signature verification)
* - `iframe`: Runs in sandboxed iframe with postMessage API bridge (for UI extensions)
* - `worker`: Runs in Web Worker with postMessage API bridge (for command-only extensions)
* - `wasm`: Runs in WASM sandbox (QuickJS) for logic-only extensions
*/
export type SandboxTrustLevel = 'core' | 'iframe' | 'worker' | 'wasm';
/**
* Permissions that sandboxed extensions can request.
*
* @remarks
* Permissions follow a least-privilege model. Extensions must explicitly
* request each permission they need.
*/
export type SandboxPermission =
| 'api:read'
| 'api:write'
| 'sqllab:read'
| 'sqllab:execute'
| 'dashboard:read'
| 'dashboard:write'
| 'chart:read'
| 'chart:write'
| 'user:read'
| 'notification:show'
| 'modal:open'
| 'navigation:redirect'
| 'clipboard:write'
| 'download:file';
/**
* Content Security Policy configuration for iframe sandboxes.
*/
export interface ContentSecurityPolicy {
defaultSrc?: string[];
scriptSrc?: string[];
styleSrc?: string[];
imgSrc?: string[];
fontSrc?: string[];
connectSrc?: string[];
frameSrc?: string[];
}
/**
* Resource limits for WASM sandbox execution.
*/
export interface WASMResourceLimits {
/** Maximum memory in bytes (default: 10MB) */
maxMemory?: number;
/** Maximum execution time in milliseconds (default: 5000ms) */
maxExecutionTime?: number;
/** Maximum call stack depth (default: 1000) */
maxStackSize?: number;
}
/**
* Sandbox configuration for extension.json manifest.
*
* @example
* ```json
* {
* "sandbox": {
* "trustLevel": "iframe",
* "permissions": ["sqllab:read", "notification:show"],
* "csp": {
* "connectSrc": ["https://api.example.com"]
* }
* }
* }
* ```
*/
export interface SandboxManifest {
/**
* Trust level required for this extension.
* @default 'iframe'
*/
trustLevel?: SandboxTrustLevel;
/**
* Permissions requested by the extension.
*/
permissions?: SandboxPermission[];
/**
* Custom CSP directives (for iframe sandboxes).
*/
csp?: ContentSecurityPolicy;
/**
* Resource limits (for WASM sandboxes).
*/
resourceLimits?: WASMResourceLimits;
/**
* Whether this extension requires signature verification.
* Required for 'core' trust level.
*/
requiresSignature?: boolean;
}
// ============================================================================
// Sandboxed Extension Client API
// ============================================================================
/**
* SQL Lab tab information available to sandboxed extensions.
*/
export interface SQLLabTab {
id: string;
title: string;
databaseId: number | null;
catalog: string | null;
schema: string | null;
sql: string;
}
/**
* Query result information available to sandboxed extensions.
*/
export interface QueryResult {
queryId: string;
status: 'pending' | 'running' | 'success' | 'error';
data: Record<string, unknown>[] | null;
columns: string[];
error: string | null;
}
/**
* Dashboard context available to sandboxed extensions.
*/
export interface DashboardContext {
id: number;
title: string;
slug: string | null;
}
/**
* Dashboard filter information.
*/
export interface DashboardFilter {
id: string;
column: string;
value: unknown;
}
/**
* Chart data available to sandboxed extensions.
*/
export interface ChartData {
chartId: number;
data: Record<string, unknown>[];
columns: string[];
}
/**
* User information available to sandboxed extensions.
*/
export interface UserInfo {
id: number;
username: string;
firstName: string;
lastName: string;
email: string;
roles: string[];
}
/**
* Modal dialog configuration.
*/
export interface ModalConfig {
title: string;
content: string;
okText?: string;
cancelText?: string;
type?: 'info' | 'confirm' | 'warning' | 'error';
}
/**
* Modal dialog result.
*/
export interface ModalResult {
confirmed: boolean;
}
/**
* API available to sandboxed extensions running in iframes.
*
* @remarks
* This interface is available as `window.superset` inside sandboxed iframes.
* Each method requires specific permissions to be granted in the extension manifest.
*
* @example
* ```typescript
* // Inside a sandboxed extension
* const tab = await window.superset.sqlLab.getCurrentTab();
* await window.superset.ui.showNotification('Hello!', 'success');
* ```
*/
export interface SandboxedExtensionAPI {
/** SQL Lab APIs (requires sqllab:read or sqllab:execute) */
sqlLab: {
getCurrentTab(): Promise<SQLLabTab | null>;
getQueryResults(queryId: string): Promise<QueryResult | null>;
};
/** Dashboard APIs (requires dashboard:read) */
dashboard: {
getContext(): Promise<DashboardContext | null>;
getFilters(): Promise<DashboardFilter[]>;
};
/** Chart APIs (requires chart:read) */
chart: {
getData(chartId: number): Promise<ChartData | null>;
};
/** User APIs (requires user:read) */
user: {
getCurrentUser(): Promise<UserInfo>;
};
/** UI APIs */
ui: {
showNotification(
message: string,
type: 'info' | 'success' | 'warning' | 'error',
): void;
openModal(config: ModalConfig): Promise<ModalResult>;
navigateTo(path: string): void;
};
/** Utility APIs */
utils: {
copyToClipboard(text: string): Promise<boolean>;
downloadFile(data: Blob | string, filename: string): void;
getCSRFToken(): Promise<string>;
};
/** Subscribe to events from the host */
on(eventName: string, handler: (data: unknown) => void): () => void;
}
/**
* Note: In sandboxed iframes, window.superset is available as SandboxedExtensionAPI.
* The actual type declaration is injected into the iframe's runtime, not here,
* to avoid conflicts with the main Superset application's window type.
*/

View File

@@ -18,8 +18,45 @@
*/
import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core';
import type { contributions, core } from '@apache-superset/core';
import type { contributions, core, sandbox } from '@apache-superset/core';
import { ExtensionContext } from '../core/models';
import { commands } from '../core/commands';
import { WASMSandbox, SandboxConfig } from './sandbox';
import { SandboxManager } from './sandbox/SandboxManager';
/**
* Tracked sandboxed extension instance.
*/
interface SandboxedExtensionInstance {
/** Extension ID */
extensionId: string;
/** Extension name */
extensionName: string;
/** Trust level of the sandbox */
trustLevel: sandbox.SandboxTrustLevel;
/** WASM sandbox instance (for 'wasm' trust level) */
wasmSandbox?: WASMSandbox;
/** Sandbox configuration */
config: SandboxConfig;
/** URL to fetch worker code from (for 'worker' trust level) */
workerCodeUrl?: string;
/** Whether the worker is currently being initialized */
workerInitializing?: boolean;
/** Promise that resolves when worker is ready (for deduplication) */
workerInitPromise?: Promise<void>;
}
/**
* Configuration for trusted extension sources.
*/
interface TrustConfiguration {
/** List of trusted extension IDs that can run as 'core' */
trustedExtensions: string[];
/** Whether to allow unsigned extensions to run as 'core' */
allowUnsignedCore: boolean;
/** Default trust level for extensions without explicit configuration */
defaultTrustLevel: sandbox.SandboxTrustLevel;
}
class ExtensionsManager {
private static instance: ExtensionsManager;
@@ -37,6 +74,17 @@ class ExtensionsManager {
}
> = new Map();
/** Sandboxed extension instances */
private sandboxedExtensions: Map<string, SandboxedExtensionInstance> =
new Map();
/** Trust configuration for extension loading */
private trustConfig: TrustConfiguration = {
trustedExtensions: [],
allowUnsignedCore: false,
defaultTrustLevel: 'iframe',
};
// eslint-disable-next-line no-useless-constructor
private constructor() {
// Private constructor for singleton pattern
@@ -71,17 +119,53 @@ class ExtensionsManager {
/**
* Initializes an extension by its instance.
* If the extension has a remote entry, it will load the module.
* The extension is loaded based on its trust level:
* - 'core': Loaded via Module Federation in main context (requires trust)
* - 'iframe': Loaded in sandboxed iframe with postMessage API
* - 'worker': Loaded in Web Worker with postMessage API (command-only)
* - 'wasm': Loaded in WASM sandbox for logic-only extensions
*
* @param extension The extension to initialize.
*/
public async initializeExtension(extension: core.Extension) {
try {
let loadedExtension = extension;
if (extension.remoteEntry) {
loadedExtension = await this.loadModule(extension);
this.enableExtension(loadedExtension);
// Use backend-validated trust level if available, otherwise fall back to frontend determination
const trustLevel =
extension.trustLevel ?? this.determineTrustLevel(extension);
// Build informative log message
let logMessage = `Initializing extension ${extension.name} with trust level: ${trustLevel}`;
if (
extension.signatureValid !== undefined &&
extension.signatureValid !== null
) {
logMessage += ` (signature: ${extension.signatureValid ? 'valid' : 'invalid'})`;
}
this.extensionIndex.set(loadedExtension.id, loadedExtension);
if (extension.trustDowngraded) {
logMessage += ` [downgraded: ${extension.trustDowngradeReason}]`;
}
logging.info(logMessage);
switch (trustLevel) {
case 'core':
await this.initializeCoreExtension(extension);
break;
case 'iframe':
await this.initializeIframeSandboxedExtension(extension);
break;
case 'worker':
await this.initializeWorkerSandboxedExtension(extension);
break;
case 'wasm':
await this.initializeWASMSandboxedExtension(extension);
break;
default:
logging.error(
`Unknown trust level '${trustLevel}' for extension ${extension.name}`,
);
}
this.extensionIndex.set(extension.id, extension);
} catch (error) {
logging.error(
`Failed to initialize extension ${extension.name}\n`,
@@ -90,6 +174,263 @@ class ExtensionsManager {
}
}
/**
* Initialize a trusted extension using Module Federation (Tier 1).
*/
private async initializeCoreExtension(
extension: core.Extension,
): Promise<void> {
if (!extension.remoteEntry) {
logging.warn(
`Core extension ${extension.name} has no remote entry, skipping`,
);
return;
}
const loadedExtension = await this.loadModule(extension);
this.enableExtension(loadedExtension);
}
/**
* Initialize an iframe-sandboxed extension (Tier 2).
*
* @remarks
* For iframe sandboxed extensions, we don't load the module directly.
* Instead, we store the configuration and let the IframeSandbox component
* handle the loading when the extension's view is rendered.
*/
private async initializeIframeSandboxedExtension(
extension: core.Extension,
): Promise<void> {
const config: SandboxConfig = {
trustLevel: 'iframe',
permissions: extension.sandbox?.permissions ?? [],
csp: extension.sandbox?.csp,
};
this.sandboxedExtensions.set(extension.id, {
extensionId: extension.id,
extensionName: extension.name,
trustLevel: 'iframe',
config,
});
// Index contributions from the extension manifest
// (iframe extensions declare contributions in extension.json)
if (extension.contributions) {
this.indexContributions(extension);
}
logging.info(`Iframe sandbox registered for extension ${extension.name}`);
}
/**
* Initialize a worker-sandboxed extension for command-only extensions.
*
* @remarks
* Worker sandboxed extensions run in a Web Worker with no DOM access.
* They are ideal for extensions that only register commands and don't
* need to render any UI. The worker is created lazily on first command
* to avoid loading unused extensions.
*/
private async initializeWorkerSandboxedExtension(
extension: core.Extension,
): Promise<void> {
const config: SandboxConfig = {
trustLevel: 'worker',
permissions: extension.sandbox?.permissions ?? [],
};
// Determine the URL to fetch worker code from
// Priority: workerEntry (if added to manifest) > derived from remoteEntry
const workerCodeUrl = this.getWorkerCodeUrl(extension);
this.sandboxedExtensions.set(extension.id, {
extensionId: extension.id,
extensionName: extension.name,
trustLevel: 'worker',
config,
workerCodeUrl,
});
// Index contributions from the extension manifest
if (extension.contributions) {
this.indexContributions(extension);
}
logging.info(
`Worker sandbox registered for extension ${extension.name} (lazy loading enabled)`,
);
}
/**
* Get the URL to fetch worker code from for a worker extension.
*
* @remarks
* Worker extensions need their code as a plain JS string (not Module Federation).
* Priority: workerEntry from manifest > derived from remoteEntry > API endpoint
*/
private getWorkerCodeUrl(extension: core.Extension): string {
// If the extension explicitly specifies a workerEntry, use it
if (extension.workerEntry) {
// If it's a relative path, prepend the extension's base URL
if (!extension.workerEntry.startsWith('http') && extension.remoteEntry) {
const baseUrl = extension.remoteEntry.replace(/\/[^/]+$/, '');
return `${baseUrl}/${extension.workerEntry}`;
}
return extension.workerEntry;
}
// If the extension has a remoteEntry, derive worker URL from it
// Convention: worker.js is in the same directory as remoteEntry
if (extension.remoteEntry) {
const baseUrl = extension.remoteEntry.replace(/\/[^/]+$/, '');
return `${baseUrl}/worker.js`;
}
// Fallback: use extension API endpoint
return `/api/v1/extensions/${extension.id}/worker.js`;
}
/**
* Initialize a WASM-sandboxed extension (Tier 3).
*
* @remarks
* WASM sandboxed extensions are for logic-only code (formatters, validators).
* They are initialized lazily when first used.
*/
private async initializeWASMSandboxedExtension(
extension: core.Extension,
): Promise<void> {
const config: SandboxConfig = {
trustLevel: 'wasm',
permissions: extension.sandbox?.permissions ?? [],
resourceLimits: extension.sandbox?.resourceLimits,
};
this.sandboxedExtensions.set(extension.id, {
extensionId: extension.id,
extensionName: extension.name,
trustLevel: 'wasm',
config,
});
// Index contributions from the extension manifest
if (extension.contributions) {
this.indexContributions(extension);
}
logging.info(`WASM sandbox registered for extension ${extension.name}`);
}
/**
* Determine the trust level for an extension.
*
* @remarks
* Trust level is determined by:
* 1. Explicit sandbox.trustLevel in extension manifest
* 2. Whether the extension is in the trusted list (for 'core')
* 3. Default trust level from configuration
*/
private determineTrustLevel(
extension: core.Extension,
): sandbox.SandboxTrustLevel {
// Check explicit manifest configuration
const manifestTrustLevel = extension.sandbox?.trustLevel;
if (manifestTrustLevel === 'core') {
// Verify the extension is allowed to run as core
if (
this.trustConfig.trustedExtensions.includes(extension.id) ||
this.trustConfig.allowUnsignedCore
) {
return 'core';
}
logging.warn(
`Extension ${extension.name} requested 'core' trust level but is not trusted. ` +
`Falling back to '${this.trustConfig.defaultTrustLevel}'.`,
);
return this.trustConfig.defaultTrustLevel;
}
if (manifestTrustLevel) {
return manifestTrustLevel;
}
// Legacy extensions without sandbox config default to core for backward compatibility
// This can be changed to 'iframe' once extensions are migrated
if (!extension.sandbox) {
logging.info(
`Extension ${extension.name} has no sandbox config, using legacy 'core' mode`,
);
return 'core';
}
return this.trustConfig.defaultTrustLevel;
}
/**
* Configure trust settings for extension loading.
*
* @param config Trust configuration options
*/
public configureTrust(config: Partial<TrustConfiguration>): void {
this.trustConfig = {
...this.trustConfig,
...config,
};
logging.info('Trust configuration updated:', this.trustConfig);
}
/**
* Get the sandboxed extension instance for an extension ID.
*
* @param extensionId The extension ID
* @returns The sandboxed extension instance, or undefined if not sandboxed
*/
public getSandboxedExtension(
extensionId: string,
): SandboxedExtensionInstance | undefined {
return this.sandboxedExtensions.get(extensionId);
}
/**
* Check if an extension is sandboxed.
*
* @param extensionId The extension ID
* @returns True if the extension is running in a sandbox
*/
public isExtensionSandboxed(extensionId: string): boolean {
return this.sandboxedExtensions.has(extensionId);
}
/**
* Get the sandbox configuration for a sandboxed view contribution.
*
* @param viewId The view contribution ID
* @returns Sandbox config for rendering the view, or null if not sandboxed
*/
public getSandboxConfigForView(viewId: string): {
extensionId: string;
config: SandboxConfig;
} | null {
// Find which extension owns this view
for (const [extensionId, instance] of this.sandboxedExtensions) {
const extension = this.extensionIndex.get(extensionId);
if (extension?.contributions?.views) {
for (const views of Object.values(extension.contributions.views)) {
if (views.some(v => v.id === viewId)) {
return {
extensionId,
config: instance.config,
};
}
}
}
}
return null;
}
/**
* Enables an extension by its instance.
* @param extension The extension to enable.
@@ -211,15 +552,149 @@ class ExtensionsManager {
/**
* Indexes contributions from an extension for quick retrieval.
* For sandboxed extensions, also registers proxy commands.
* @param extension The extension to index.
*/
private indexContributions(extension: core.Extension): void {
const { contributions, id } = extension;
const { contributions: extensionContributions, id } = extension;
this.extensionContributions.set(id, {
menus: contributions.menus,
views: contributions.views,
commands: contributions.commands,
menus: extensionContributions.menus,
views: extensionContributions.views,
commands: extensionContributions.commands,
});
// For sandboxed extensions, register proxy commands
if (this.sandboxedExtensions.has(id) && extensionContributions.commands) {
this.registerSandboxedCommands(id, extensionContributions.commands);
}
}
/**
* Register proxy commands for a sandboxed extension.
* These commands forward execution to the sandbox via SandboxManager.
* For worker sandboxes, the worker is created lazily on first command.
*/
private registerSandboxedCommands(
extensionId: string,
commandContributions: contributions.CommandContribution[],
): void {
const sandboxManager = SandboxManager.getInstance();
const instance = this.sandboxedExtensions.get(extensionId);
for (const cmd of commandContributions) {
logging.info(
`Registering sandboxed proxy command: ${cmd.command} for extension ${extensionId}`,
);
if (instance?.trustLevel === 'worker') {
// Worker sandbox: lazy initialization on first command
commands.registerCommand(cmd.command, async (...args: unknown[]) => {
logging.info(`Executing worker sandboxed command: ${cmd.command}`);
await this.ensureWorkerSandboxReady(extensionId);
sandboxManager.dispatchCommandToExtension(
extensionId,
cmd.command,
args,
);
});
} else {
// Iframe/WASM sandbox: direct dispatch
commands.registerCommand(cmd.command, (...args: unknown[]) => {
logging.info(`Executing sandboxed command: ${cmd.command}`);
sandboxManager.dispatchCommandToExtension(
extensionId,
cmd.command,
args,
);
});
}
}
}
/**
* Ensure the worker sandbox is ready for an extension.
* Creates the worker lazily on first use.
*/
private async ensureWorkerSandboxReady(extensionId: string): Promise<void> {
const sandboxManager = SandboxManager.getInstance();
const instance = this.sandboxedExtensions.get(extensionId);
if (!instance || instance.trustLevel !== 'worker') {
return;
}
// Check if worker already exists
if (sandboxManager.hasReadySandbox(extensionId)) {
return;
}
// Check if already initializing (deduplicate concurrent calls)
if (instance.workerInitPromise) {
await instance.workerInitPromise;
return;
}
// Start initialization
instance.workerInitializing = true;
instance.workerInitPromise = this.createWorkerSandbox(
extensionId,
instance,
);
try {
await instance.workerInitPromise;
} finally {
instance.workerInitializing = false;
instance.workerInitPromise = undefined;
}
}
/**
* Create the worker sandbox for an extension.
* Fetches the code and initializes the worker.
*/
private async createWorkerSandbox(
extensionId: string,
instance: SandboxedExtensionInstance,
): Promise<void> {
const sandboxManager = SandboxManager.getInstance();
if (!instance.workerCodeUrl) {
logging.error(`No worker code URL for extension ${extensionId}`);
return;
}
logging.info(
`Fetching worker code for extension ${extensionId} from ${instance.workerCodeUrl}`,
);
try {
// Fetch the worker code
const response = await fetch(instance.workerCodeUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch worker code: ${response.status} ${response.statusText}`,
);
}
const code = await response.text();
// Create the worker sandbox
await sandboxManager.createWorkerSandbox(
extensionId,
instance.extensionName,
code,
instance.config,
);
logging.info(`Worker sandbox created for extension ${extensionId}`);
} catch (error) {
logging.error(
`Failed to create worker sandbox for ${extensionId}:`,
error,
);
throw error;
}
}
/**
@@ -317,6 +792,27 @@ class ExtensionsManager {
public getExtension(id: string): core.Extension | undefined {
return this.extensionIndex.get(id);
}
/**
* Dispose of all sandboxed extensions and clean up resources.
*/
public disposeAll(): void {
for (const [id, instance] of this.sandboxedExtensions) {
try {
if (instance.wasmSandbox) {
instance.wasmSandbox.dispose();
}
this.deactivateExtension(id);
} catch (error) {
logging.warn(`Error disposing extension ${id}:`, error);
}
}
this.sandboxedExtensions.clear();
this.extensionIndex.clear();
this.contextIndex.clear();
this.extensionContributions.clear();
}
}
export default ExtensionsManager;
export type { TrustConfiguration };

View File

@@ -0,0 +1,530 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview IframeSandbox component for Tier 2 sandboxed extensions.
*
* This component renders a sandboxed iframe that hosts extension UI code
* with browser-enforced isolation. Communication with the extension happens
* via the SandboxBridge postMessage API.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { css, styled, SupersetTheme } from '@apache-superset/core/ui';
import { logging } from '@apache-superset/core';
import { SandboxBridge } from './SandboxBridge';
import {
SandboxConfig,
SandboxError,
ContentSecurityPolicy,
} from './types';
import { SandboxedExtensionHostImpl } from './SandboxedExtensionHost';
/**
* Props for the IframeSandbox component.
*/
interface IframeSandboxProps {
/** Unique ID for this sandbox instance */
sandboxId: string;
/** Extension ID being sandboxed */
extensionId: string;
/** Extension name for display purposes */
extensionName: string;
/** URL to the extension's entry point */
entryUrl?: string;
/** Inline HTML content (alternative to entryUrl) */
inlineContent?: string;
/** Sandbox configuration */
config: SandboxConfig;
/** Callback when sandbox is ready */
onReady?: () => void;
/** Callback when sandbox encounters an error */
onError?: (error: SandboxError) => void;
/** Additional CSS class name */
className?: string;
/** Custom styles */
style?: React.CSSProperties;
}
const SandboxContainer = styled.div`
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
`;
const SandboxIframe = styled.iframe`
width: 100%;
height: 100%;
border: none;
background: transparent;
`;
const LoadingOverlay = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: ${({ theme }) => (theme as SupersetTheme).colorBgContainer};
`;
const ErrorOverlay = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: ${({ theme }) => (theme as SupersetTheme).colorBgContainer};
color: ${({ theme }) => (theme as SupersetTheme).colorError};
padding: ${({ theme }) => (theme as SupersetTheme).paddingLG}px;
text-align: center;
`;
/**
* Build Content Security Policy string from configuration.
*/
function buildCSP(csp?: ContentSecurityPolicy): string {
const directives: string[] = [];
// Start with restrictive defaults
const defaultDirectives: ContentSecurityPolicy = {
defaultSrc: ["'none'"],
scriptSrc: ["'unsafe-inline'"], // Required for inline scripts in srcdoc
styleSrc: ["'unsafe-inline'"], // Required for inline styles
imgSrc: ['data:', 'blob:'],
fontSrc: ['data:'],
connectSrc: ["'none'"], // No network access by default
frameSrc: ["'none'"],
...csp,
};
if (defaultDirectives.defaultSrc?.length) {
directives.push(`default-src ${defaultDirectives.defaultSrc.join(' ')}`);
}
if (defaultDirectives.scriptSrc?.length) {
directives.push(`script-src ${defaultDirectives.scriptSrc.join(' ')}`);
}
if (defaultDirectives.styleSrc?.length) {
directives.push(`style-src ${defaultDirectives.styleSrc.join(' ')}`);
}
if (defaultDirectives.imgSrc?.length) {
directives.push(`img-src ${defaultDirectives.imgSrc.join(' ')}`);
}
if (defaultDirectives.fontSrc?.length) {
directives.push(`font-src ${defaultDirectives.fontSrc.join(' ')}`);
}
if (defaultDirectives.connectSrc?.length) {
directives.push(`connect-src ${defaultDirectives.connectSrc.join(' ')}`);
}
if (defaultDirectives.frameSrc?.length) {
directives.push(`frame-src ${defaultDirectives.frameSrc.join(' ')}`);
}
return directives.join('; ');
}
/**
* Generate the HTML document to load in the sandboxed iframe.
*
* @remarks
* This creates a minimal HTML document that:
* 1. Sets up the Content Security Policy
* 2. Includes the SandboxBridgeClient for communication
* 3. Loads the extension's entry point
*/
function generateSandboxDocument(
extensionId: string,
extensionName: string,
entryUrl?: string,
inlineContent?: string,
csp?: ContentSecurityPolicy,
): string {
const cspString = buildCSP(csp);
// The bridge client code that will be injected into the sandbox
const bridgeClientCode = `
// Minimal SandboxBridgeClient for communication with host
class SandboxBridgeClient {
constructor(extensionId) {
this.extensionId = extensionId;
this.pendingRequests = new Map();
this.eventHandlers = new Map();
this.callTimeout = 30000;
}
connect(parentWindow) {
this.targetWindow = parentWindow;
window.addEventListener('message', (e) => this.handleMessage(e));
this.sendReady();
}
call(method, args = []) {
return new Promise((resolve, reject) => {
if (!this.targetWindow) {
reject({ code: 'NOT_CONNECTED', message: 'Not connected' });
return;
}
const id = Math.random().toString(36).slice(2);
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject({ code: 'TIMEOUT', message: 'Call timed out' });
}, this.callTimeout);
this.pendingRequests.set(id, { resolve, reject, timeout });
this.targetWindow.postMessage({
type: 'api-call',
id,
extensionId: this.extensionId,
method,
args
}, '*');
});
}
on(eventName, handler) {
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, new Set());
}
this.eventHandlers.get(eventName).add(handler);
return () => this.eventHandlers.get(eventName)?.delete(handler);
}
handleMessage(event) {
const msg = event.data;
if (!msg || msg.extensionId !== this.extensionId) return;
if (msg.type === 'api-response') {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(msg.id);
msg.error ? pending.reject(msg.error) : pending.resolve(msg.result);
}
} else if (msg.type === 'event') {
const handlers = this.eventHandlers.get(msg.eventName);
if (handlers) handlers.forEach(h => { try { h(msg.data); } catch(e) { console.error(e); }});
}
}
sendReady() {
if (!this.targetWindow) return;
this.targetWindow.postMessage({
type: 'ready',
id: Math.random().toString(36).slice(2),
extensionId: this.extensionId
}, '*');
}
}
// Command registry for extension commands
const commandRegistry = new Map();
// Initialize the bridge
const superset = {
bridge: new SandboxBridgeClient('${extensionId}'),
// Command system
commands: {
register: (command, handler) => {
commandRegistry.set(command, handler);
return () => commandRegistry.delete(command);
},
execute: async (command, ...args) => {
const handler = commandRegistry.get(command);
if (handler) {
return handler(...args);
}
console.warn('Command not found:', command);
return undefined;
},
},
// Convenience API wrappers
sqlLab: {
getCurrentTab: () => superset.bridge.call('sqlLab.getCurrentTab'),
getQueryResults: (id) => superset.bridge.call('sqlLab.getQueryResults', [id]),
},
dashboard: {
getContext: () => superset.bridge.call('dashboard.getContext'),
getFilters: () => superset.bridge.call('dashboard.getFilters'),
},
chart: {
getData: (id) => superset.bridge.call('chart.getData', [id]),
},
user: {
getCurrentUser: () => superset.bridge.call('user.getCurrentUser'),
},
ui: {
showNotification: (msg, type) => superset.bridge.call('ui.showNotification', [msg, type]),
openModal: (config) => superset.bridge.call('ui.openModal', [config]),
navigateTo: (path) => superset.bridge.call('ui.navigateTo', [path]),
},
utils: {
copyToClipboard: (text) => superset.bridge.call('utils.copyToClipboard', [text]),
downloadFile: (data, filename) => superset.bridge.call('utils.downloadFile', [data, filename]),
getCSRFToken: () => superset.bridge.call('utils.getCSRFToken'),
},
on: (event, handler) => superset.bridge.on(event, handler),
};
// Connect to parent
superset.bridge.connect(window.parent);
// Listen for command events from the host
superset.on('command', ({ command, args }) => {
superset.commands.execute(command, ...(args || []));
});
// Make available globally
window.superset = superset;
`;
// If inline content is provided, use it directly
if (inlineContent) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${cspString}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${extensionName}</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script>${bridgeClientCode}</script>
${inlineContent}
</body>
</html>`;
}
// Otherwise, load from URL
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${cspString}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${extensionName}</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script>${bridgeClientCode}</script>
${entryUrl ? `<script src="${entryUrl}"></script>` : ''}
</body>
</html>`;
}
/**
* IframeSandbox component for rendering sandboxed extensions.
*
* @remarks
* This component creates a secure sandbox environment for running
* untrusted extension code. The sandbox provides:
*
* - Browser-enforced isolation via iframe sandbox attribute
* - Content Security Policy for resource restrictions
* - postMessage-based API bridge for controlled host access
* - Automatic cleanup on unmount
*
* @example
* ```tsx
* <IframeSandbox
* sandboxId="ext-123"
* extensionId="my-extension"
* extensionName="My Extension"
* config={{
* trustLevel: 'iframe',
* permissions: ['sqllab:read', 'notification:show'],
* }}
* onReady={() => console.log('Extension ready')}
* onError={(err) => console.error('Extension error:', err)}
* />
* ```
*/
export function IframeSandbox({
sandboxId,
extensionId,
extensionName,
entryUrl,
inlineContent,
config,
onReady,
onError,
className,
style,
}: IframeSandboxProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const bridgeRef = useRef<SandboxBridge | null>(null);
const hostImplRef = useRef<SandboxedExtensionHostImpl | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<SandboxError | null>(null);
// Generate the sandbox document content
const sandboxDocument = useMemo(
() =>
generateSandboxDocument(
extensionId,
extensionName,
entryUrl,
inlineContent,
config.csp,
),
[extensionId, extensionName, entryUrl, inlineContent, config.csp],
);
// Handle ready callback
const handleReady = useCallback(() => {
setIsLoading(false);
logging.info(`IframeSandbox ${sandboxId} ready for extension ${extensionId}`);
onReady?.();
}, [sandboxId, extensionId, onReady]);
// Handle error callback
const handleError = useCallback(
(err: SandboxError) => {
setError(err);
setIsLoading(false);
logging.error(`IframeSandbox ${sandboxId} error:`, err);
onError?.(err);
},
[sandboxId, onError],
);
// Set up the bridge when iframe loads
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return undefined;
const handleLoad = () => {
const contentWindow = iframe.contentWindow;
if (!contentWindow) {
handleError({
code: 'NO_CONTENT_WINDOW',
message: 'Failed to access iframe content window',
});
return;
}
// Create the bridge
const bridge = new SandboxBridge({
bridgeId: sandboxId,
extensionId,
permissions: config.permissions ?? [],
});
// Create the host API implementation
const hostImpl = new SandboxedExtensionHostImpl(
extensionId,
config.permissions ?? [],
);
// Set up the API handler
bridge.onApiCall(async (method: string, args: unknown[]) =>
hostImpl.handleApiCall(method, args),
);
// Connect the bridge
bridge.connect(contentWindow, handleReady);
bridgeRef.current = bridge;
hostImplRef.current = hostImpl;
// Set a timeout for ready signal
const readyTimeout = setTimeout(() => {
if (isLoading) {
handleError({
code: 'READY_TIMEOUT',
message: 'Extension did not signal ready within timeout',
});
}
}, 10000);
return () => clearTimeout(readyTimeout);
};
iframe.addEventListener('load', handleLoad);
return () => {
iframe.removeEventListener('load', handleLoad);
bridgeRef.current?.disconnect();
bridgeRef.current = null;
hostImplRef.current = null;
};
}, [
sandboxId,
extensionId,
config.permissions,
handleReady,
handleError,
isLoading,
]);
// Determine sandbox attribute value
// We use 'allow-scripts' to let JS run, but NOT 'allow-same-origin'
// which ensures the iframe cannot access the parent's cookies, storage, etc.
const sandboxAttribute = 'allow-scripts';
return (
<SandboxContainer className={className} style={style}>
<SandboxIframe
ref={iframeRef}
sandbox={sandboxAttribute}
srcDoc={sandboxDocument}
title={`Sandbox: ${extensionName}`}
css={css`
opacity: ${isLoading || error ? 0 : 1};
transition: opacity 0.2s ease-in-out;
`}
/>
{isLoading && !error && (
<LoadingOverlay>
<span>Loading {extensionName}...</span>
</LoadingOverlay>
)}
{error && (
<ErrorOverlay>
<strong>Extension Error</strong>
<p>{error.message}</p>
<small>Code: {error.code}</small>
</ErrorOverlay>
)}
</SandboxContainer>
);
}
export default IframeSandbox;

View File

@@ -0,0 +1,206 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SandboxBridge, SandboxBridgeClient } from './SandboxBridge';
// Mock logging
jest.mock('@apache-superset/core', () => ({
logging: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
// Mock nanoid
jest.mock('nanoid', () => ({
nanoid: () => 'test-id-123',
}));
describe('SandboxBridge', () => {
let bridge: SandboxBridge;
let mockWindow: Window;
beforeEach(() => {
jest.clearAllMocks();
mockWindow = {
postMessage: jest.fn(),
} as unknown as Window;
bridge = new SandboxBridge({
bridgeId: 'test-bridge',
extensionId: 'test-extension',
permissions: ['sqllab:read', 'notification:show'],
});
});
afterEach(() => {
bridge.disconnect();
});
test('connect sets up message listener', () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
bridge.connect(mockWindow);
expect(addEventListenerSpy).toHaveBeenCalledWith(
'message',
expect.any(Function),
);
addEventListenerSpy.mockRestore();
});
test('disconnect removes message listener and clears state', () => {
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
bridge.connect(mockWindow);
bridge.disconnect();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'message',
expect.any(Function),
);
removeEventListenerSpy.mockRestore();
});
test('hasPermission returns true for granted permissions', () => {
expect(bridge.hasPermission('sqllab:read')).toBe(true);
expect(bridge.hasPermission('notification:show')).toBe(true);
});
test('hasPermission returns false for denied permissions', () => {
expect(bridge.hasPermission('dashboard:write')).toBe(false);
expect(bridge.hasPermission('sqllab:execute')).toBe(false);
});
test('getPermissions returns all granted permissions', () => {
const permissions = bridge.getPermissions();
expect(permissions).toContain('sqllab:read');
expect(permissions).toContain('notification:show');
expect(permissions).toHaveLength(2);
});
test('emitEvent posts message to target window', () => {
bridge.connect(mockWindow);
bridge.emitEvent('test-event', { foo: 'bar' });
expect(mockWindow.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'event',
extensionId: 'test-extension',
eventName: 'test-event',
data: { foo: 'bar' },
}),
'*',
);
});
test('emitEvent does nothing when not connected', () => {
bridge.emitEvent('test-event', { foo: 'bar' });
expect(mockWindow.postMessage).not.toHaveBeenCalled();
});
});
describe('SandboxBridgeClient', () => {
let client: SandboxBridgeClient;
let mockParentWindow: Window;
beforeEach(() => {
jest.clearAllMocks();
mockParentWindow = {
postMessage: jest.fn(),
} as unknown as Window;
client = new SandboxBridgeClient('test-extension');
});
afterEach(() => {
client.disconnect();
});
test('connect sends ready signal', () => {
client.connect(mockParentWindow);
expect(mockParentWindow.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'ready',
extensionId: 'test-extension',
}),
'*',
);
});
test('call returns promise that resolves on response', async () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
client.connect(mockParentWindow);
// Get the message handler
const messageHandler = addEventListenerSpy.mock.calls.find(
call => call[0] === 'message',
)?.[1] as EventListener;
expect(messageHandler).toBeDefined();
// Start the call
const callPromise = client.call<string>('test.method', ['arg1']);
// Simulate response
const responseEvent = new MessageEvent('message', {
data: {
type: 'api-response',
id: 'test-id-123',
extensionId: 'test-extension',
result: 'test-result',
},
});
messageHandler(responseEvent);
const result = await callPromise;
expect(result).toBe('test-result');
addEventListenerSpy.mockRestore();
});
test('on registers event handler', () => {
client.connect(mockParentWindow);
const handler = jest.fn();
const unsubscribe = client.on('test-event', handler);
expect(typeof unsubscribe).toBe('function');
});
test('on unsubscribe removes handler', () => {
client.connect(mockParentWindow);
const handler = jest.fn();
const unsubscribe = client.on('test-event', handler);
unsubscribe();
// Handler should no longer be called for events
// (This would require simulating an event and checking the handler isn't called)
});
});

View File

@@ -0,0 +1,639 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Sandbox Bridge for secure postMessage RPC communication.
*
* This module provides a secure communication layer between the Superset host
* application and sandboxed extensions running in iframes. It implements a
* request/response pattern with correlation IDs for reliable async communication.
*/
import { logging } from '@apache-superset/core';
import { nanoid } from 'nanoid';
import {
SandboxMessage,
SandboxApiCallMessage,
SandboxApiResponseMessage,
SandboxEventMessage,
SandboxError,
SandboxPermission,
} from './types';
/**
* Pending request tracker for async responses.
*/
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: SandboxError) => void;
timeout: ReturnType<typeof setTimeout>;
}
/**
* Options for initializing the SandboxBridge.
*/
interface SandboxBridgeOptions {
/** Unique ID for this bridge instance */
bridgeId: string;
/** Extension ID this bridge is communicating with */
extensionId: string;
/** Permissions granted to this extension */
permissions: SandboxPermission[];
/** Timeout for API calls in milliseconds */
callTimeout?: number;
}
/**
* Host-side sandbox bridge for communicating with iframe sandboxes.
*
* @remarks
* The SandboxBridge handles all communication between the Superset host
* and sandboxed extensions. It provides:
*
* - Request/response pattern with correlation IDs
* - Permission checking before executing API calls
* - Event subscriptions with automatic cleanup
* - Timeout handling for unresponsive extensions
*
* @example
* ```typescript
* const bridge = new SandboxBridge({
* bridgeId: 'sandbox-123',
* extensionId: 'my-extension',
* permissions: ['sqllab:read', 'notification:show'],
* });
*
* bridge.connect(iframeElement.contentWindow);
*
* // Listen for API calls from the extension
* bridge.onApiCall(async (method, args) => {
* // Handle the call based on method
* });
* ```
*/
export class SandboxBridge {
private bridgeId: string;
private extensionId: string;
private permissions: Set<SandboxPermission>;
// @ts-expect-error Reserved for future use in timeout handling
private _callTimeout: number;
private targetWindow: Window | null = null;
private pendingRequests: Map<string, PendingRequest> = new Map();
private eventListeners: Map<string, Set<(data: unknown) => void>> = new Map();
private messageHandler: ((event: MessageEvent) => void) | null = null;
private apiHandler:
| ((method: string, args: unknown[]) => Promise<unknown>)
| null = null;
private readyCallback: (() => void) | null = null;
private isConnected = false;
constructor(options: SandboxBridgeOptions) {
this.bridgeId = options.bridgeId;
this.extensionId = options.extensionId;
this.permissions = new Set(options.permissions);
this._callTimeout = options.callTimeout ?? 30000;
}
/**
* Connect the bridge to an iframe's content window.
*
* @param targetWindow - The iframe's contentWindow to communicate with
* @param onReady - Optional callback when the extension signals ready
*/
connect(targetWindow: Window, onReady?: () => void): void {
if (this.isConnected) {
logging.warn(
`SandboxBridge ${this.bridgeId} is already connected. Disconnecting first.`,
);
this.disconnect();
}
this.targetWindow = targetWindow;
this.readyCallback = onReady ?? null;
// Set up message listener
this.messageHandler = (event: MessageEvent) => {
this.handleMessage(event);
};
window.addEventListener('message', this.messageHandler);
this.isConnected = true;
logging.info(`SandboxBridge ${this.bridgeId} connected`);
}
/**
* Disconnect the bridge and clean up resources.
*/
disconnect(): void {
if (this.messageHandler) {
window.removeEventListener('message', this.messageHandler);
this.messageHandler = null;
}
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject({
code: 'BRIDGE_DISCONNECTED',
message: 'Sandbox bridge was disconnected',
});
}
this.pendingRequests.clear();
// Clear event listeners
this.eventListeners.clear();
this.targetWindow = null;
this.isConnected = false;
logging.info(`SandboxBridge ${this.bridgeId} disconnected`);
}
/**
* Register a handler for API calls from the extension.
*
* @param handler - Function to handle API calls
*/
onApiCall(
handler: (method: string, args: unknown[]) => Promise<unknown>,
): void {
this.apiHandler = handler;
}
/**
* Send an event to the sandboxed extension.
*
* @param eventName - Name of the event
* @param data - Event payload
*/
emitEvent(eventName: string, data: unknown): void {
if (!this.isConnected || !this.targetWindow) {
logging.warn(
`Cannot emit event ${eventName}: bridge is not connected`,
);
return;
}
const message: SandboxEventMessage = {
type: 'event',
id: nanoid(),
extensionId: this.extensionId,
eventName,
data,
};
this.postMessage(message);
}
/**
* Check if the extension has a specific permission.
*
* @param permission - The permission to check
* @returns true if the permission is granted
*/
hasPermission(permission: SandboxPermission): boolean {
return this.permissions.has(permission);
}
/**
* Get all granted permissions.
*/
getPermissions(): SandboxPermission[] {
return Array.from(this.permissions);
}
/**
* Handle incoming messages from the sandbox.
*/
private handleMessage(event: MessageEvent): void {
// Validate the message origin and structure
const message = event.data as SandboxMessage;
if (!message || typeof message !== 'object') {
return;
}
// Verify the message is from the correct extension
if (message.extensionId !== this.extensionId) {
return;
}
switch (message.type) {
case 'ready':
this.handleReady();
break;
case 'api-call':
this.handleApiCall(message as SandboxApiCallMessage);
break;
case 'api-response':
this.handleApiResponse(message as SandboxApiResponseMessage);
break;
case 'event-subscribe':
// Extension subscribing to events (handled separately)
break;
default:
logging.warn(
`SandboxBridge ${this.bridgeId} received unknown message type: ${message.type}`,
);
}
}
/**
* Handle ready signal from the extension.
*/
private handleReady(): void {
logging.info(
`Extension ${this.extensionId} signaled ready via bridge ${this.bridgeId}`,
);
if (this.readyCallback) {
this.readyCallback();
}
}
/**
* Handle API call from the extension.
*/
private async handleApiCall(message: SandboxApiCallMessage): Promise<void> {
const { id, method, args } = message;
try {
// Check if we have a handler
if (!this.apiHandler) {
this.sendResponse(id, undefined, {
code: 'NO_HANDLER',
message: 'No API handler registered',
});
return;
}
// Check permissions for the requested method
const requiredPermission = this.getRequiredPermission(method);
if (requiredPermission && !this.hasPermission(requiredPermission)) {
this.sendResponse(id, undefined, {
code: 'PERMISSION_DENIED',
message: `Permission '${requiredPermission}' required for method '${method}'`,
});
return;
}
// Execute the API call
const result = await this.apiHandler(method, args);
this.sendResponse(id, result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
this.sendResponse(id, undefined, {
code: 'EXECUTION_ERROR',
message: errorMessage,
});
}
}
/**
* Handle API response for a pending request.
*/
private handleApiResponse(message: SandboxApiResponseMessage): void {
const pending = this.pendingRequests.get(message.id);
if (!pending) {
logging.warn(
`Received response for unknown request: ${message.id}`,
);
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(message.error);
} else {
pending.resolve(message.result);
}
}
/**
* Send a response to an API call.
*/
private sendResponse(
requestId: string,
result?: unknown,
error?: SandboxError,
): void {
const response: SandboxApiResponseMessage = {
type: 'api-response',
id: requestId,
extensionId: this.extensionId,
result,
error,
};
this.postMessage(response);
}
/**
* Post a message to the sandboxed iframe.
*/
private postMessage(message: SandboxMessage): void {
if (!this.targetWindow) {
logging.error('Cannot post message: no target window');
return;
}
// Use '*' for targetOrigin since sandboxed iframes without allow-same-origin
// have an opaque origin that we can't specify
this.targetWindow.postMessage(message, '*');
}
/**
* Get the required permission for an API method.
*
* @remarks
* Maps API method names to their required permissions.
* Methods without a mapping are allowed without permissions.
*/
private getRequiredPermission(method: string): SandboxPermission | null {
const permissionMap: Record<string, SandboxPermission> = {
// SQL Lab
'sqlLab.getCurrentTab': 'sqllab:read',
'sqlLab.getQueryResults': 'sqllab:read',
'sqlLab.executeQuery': 'sqllab:execute',
// Dashboard
'dashboard.getContext': 'dashboard:read',
'dashboard.getFilters': 'dashboard:read',
'dashboard.setFilter': 'dashboard:write',
// Chart
'chart.getData': 'chart:read',
'chart.refresh': 'chart:write',
// User
'user.getCurrentUser': 'user:read',
// UI
'ui.showNotification': 'notification:show',
'ui.openModal': 'modal:open',
'ui.navigateTo': 'navigation:redirect',
// Utils
'utils.copyToClipboard': 'clipboard:write',
'utils.downloadFile': 'download:file',
// 'utils.getCSRFToken' - no permission required
};
return permissionMap[method] ?? null;
}
}
/**
* Client-side bridge for use inside sandboxed iframes.
*
* @remarks
* This class is used by extensions running inside sandboxed iframes
* to communicate with the Superset host. It provides a Promise-based
* API for calling host methods.
*
* @example
* ```typescript
* // Inside the sandboxed extension
* const client = new SandboxBridgeClient('my-extension');
* client.connect(window.parent);
*
* // Call host APIs
* const tab = await client.call('sqlLab.getCurrentTab');
* await client.call('ui.showNotification', ['Hello!', 'info']);
* ```
*/
export class SandboxBridgeClient {
private extensionId: string;
private targetWindow: Window | null = null;
private pendingRequests: Map<string, PendingRequest> = new Map();
private eventHandlers: Map<string, Set<(data: unknown) => void>> = new Map();
private messageHandler: ((event: MessageEvent) => void) | null = null;
private callTimeout: number;
constructor(extensionId: string, callTimeout = 30000) {
this.extensionId = extensionId;
this.callTimeout = callTimeout;
}
/**
* Connect to the parent window (Superset host).
*
* @param parentWindow - Usually window.parent
*/
connect(parentWindow: Window): void {
this.targetWindow = parentWindow;
this.messageHandler = (event: MessageEvent) => {
this.handleMessage(event);
};
window.addEventListener('message', this.messageHandler);
// Signal ready to the host
this.sendReady();
}
/**
* Disconnect from the host.
*/
disconnect(): void {
if (this.messageHandler) {
window.removeEventListener('message', this.messageHandler);
this.messageHandler = null;
}
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject({
code: 'DISCONNECTED',
message: 'Bridge disconnected',
});
}
this.pendingRequests.clear();
this.targetWindow = null;
}
/**
* Call a host API method.
*
* @param method - The API method to call (e.g., 'sqlLab.getCurrentTab')
* @param args - Arguments to pass to the method
* @returns Promise resolving to the method result
*/
call<T = unknown>(method: string, args: unknown[] = []): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.targetWindow) {
reject({
code: 'NOT_CONNECTED',
message: 'Bridge is not connected',
});
return;
}
const id = nanoid();
// Set up timeout
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject({
code: 'TIMEOUT',
message: `Call to ${method} timed out after ${this.callTimeout}ms`,
});
}, this.callTimeout);
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
const message: SandboxApiCallMessage = {
type: 'api-call',
id,
extensionId: this.extensionId,
method,
args,
};
this.targetWindow.postMessage(message, '*');
});
}
/**
* Subscribe to events from the host.
*
* @param eventName - Name of the event to subscribe to
* @param handler - Function to call when the event is received
* @returns Function to unsubscribe
*/
on(eventName: string, handler: (data: unknown) => void): () => void {
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, new Set());
}
this.eventHandlers.get(eventName)!.add(handler);
return () => {
this.eventHandlers.get(eventName)?.delete(handler);
};
}
/**
* Handle messages from the host.
*/
private handleMessage(event: MessageEvent): void {
const message = event.data as SandboxMessage;
if (!message || typeof message !== 'object') {
return;
}
if (message.extensionId !== this.extensionId) {
return;
}
switch (message.type) {
case 'api-response':
this.handleApiResponse(message as SandboxApiResponseMessage);
break;
case 'event':
this.handleEvent(message as SandboxEventMessage);
break;
default:
// Ignore unknown message types
break;
}
}
/**
* Handle API response from the host.
*/
private handleApiResponse(message: SandboxApiResponseMessage): void {
const pending = this.pendingRequests.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(message.error);
} else {
pending.resolve(message.result);
}
}
/**
* Handle event from the host.
*/
private handleEvent(message: SandboxEventMessage): void {
const handlers = this.eventHandlers.get(message.eventName);
if (handlers) {
for (const handler of handlers) {
try {
handler(message.data);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error in event handler for ${message.eventName}:`, error);
}
}
}
}
/**
* Send ready signal to the host.
*/
private sendReady(): void {
if (!this.targetWindow) {
return;
}
const message: SandboxMessage = {
type: 'ready',
id: nanoid(),
extensionId: this.extensionId,
};
this.targetWindow.postMessage(message, '*');
}
}
export default SandboxBridge;

View File

@@ -0,0 +1,156 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SandboxManager } from './SandboxManager';
import { SandboxConfig } from './types';
// Mock logging
jest.mock('@apache-superset/core', () => ({
logging: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
// Mock nanoid with incrementing IDs
const mockNanoid = jest.fn();
jest.mock('nanoid', () => ({
nanoid: () => mockNanoid(),
}));
describe('SandboxManager', () => {
let manager: SandboxManager;
let idCounter = 0;
beforeEach(() => {
jest.clearAllMocks();
// Reset the singleton and counter for each test
(SandboxManager as any).instance = undefined;
idCounter = 0;
mockNanoid.mockImplementation(() => `test-id-${++idCounter}`);
manager = SandboxManager.getInstance();
});
afterEach(() => {
manager.disposeAll();
});
const testConfig: SandboxConfig = {
trustLevel: 'iframe',
permissions: ['sqllab:read', 'notification:show'],
};
test('getInstance returns singleton', () => {
const instance1 = SandboxManager.getInstance();
const instance2 = SandboxManager.getInstance();
expect(instance1).toBe(instance2);
});
test('createSandbox creates and tracks sandbox instance', () => {
const sandboxId = manager.createSandbox('test-extension', testConfig);
expect(sandboxId).toMatch(/^sandbox-test-extension-test-id-/);
const sandbox = manager.getSandbox(sandboxId);
expect(sandbox).toBeDefined();
expect(sandbox?.extensionId).toBe('test-extension');
expect(sandbox?.isReady).toBe(false);
});
test('getSandboxesForExtension returns all sandbox IDs for an extension', () => {
manager.createSandbox('ext-1', testConfig);
manager.createSandbox('ext-1', testConfig);
manager.createSandbox('ext-2', testConfig);
const ext1Sandboxes = manager.getSandboxesForExtension('ext-1');
const ext2Sandboxes = manager.getSandboxesForExtension('ext-2');
expect(ext1Sandboxes).toHaveLength(2);
expect(ext2Sandboxes).toHaveLength(1);
});
test('hasReadySandbox returns false when no sandbox is ready', () => {
manager.createSandbox('test-extension', testConfig);
expect(manager.hasReadySandbox('test-extension')).toBe(false);
});
test('disposeSandbox removes sandbox instance', () => {
const sandboxId = manager.createSandbox('test-extension', testConfig);
expect(manager.getSandbox(sandboxId)).toBeDefined();
manager.disposeSandbox(sandboxId);
expect(manager.getSandbox(sandboxId)).toBeUndefined();
expect(manager.getSandboxesForExtension('test-extension')).toHaveLength(0);
});
test('disposeExtensionSandboxes removes all sandboxes for an extension', () => {
manager.createSandbox('ext-1', testConfig);
manager.createSandbox('ext-1', testConfig);
manager.createSandbox('ext-2', testConfig);
manager.disposeExtensionSandboxes('ext-1');
expect(manager.getSandboxesForExtension('ext-1')).toHaveLength(0);
expect(manager.getSandboxesForExtension('ext-2')).toHaveLength(1);
});
test('dispatchCommandToExtension queues command when sandbox not ready', () => {
const sandboxId = manager.createSandbox('test-extension', testConfig);
const sandbox = manager.getSandbox(sandboxId);
manager.dispatchCommandToExtension('test-extension', 'test.command', [
'arg1',
]);
expect(sandbox?.pendingCommands).toHaveLength(1);
expect(sandbox?.pendingCommands[0]).toEqual({
command: 'test.command',
args: ['arg1'],
});
});
test('onSandboxReady registers callback', () => {
const callback = jest.fn();
const unsubscribe = manager.onSandboxReady(callback);
expect(typeof unsubscribe).toBe('function');
// Unsubscribe should work
unsubscribe();
});
test('disposeAll clears all sandboxes and callbacks', () => {
const callback = jest.fn();
manager.onSandboxReady(callback);
manager.createSandbox('ext-1', testConfig);
manager.createSandbox('ext-2', testConfig);
manager.disposeAll();
expect(manager.getSandboxesForExtension('ext-1')).toHaveLength(0);
expect(manager.getSandboxesForExtension('ext-2')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,444 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview SandboxManager for managing sandboxed extension instances.
*
* This module provides centralized management of sandbox instances, including:
* - Lifecycle management (creation, disposal)
* - Command dispatch to sandboxed extensions
* - Bridge instance management
*/
import { logging } from '@apache-superset/core';
import { nanoid } from 'nanoid';
import { SandboxBridge } from './SandboxBridge';
import { SandboxedExtensionHostImpl } from './SandboxedExtensionHost';
import { WorkerSandbox } from './WorkerSandbox';
import { SandboxConfig } from './types';
/**
* Type of sandbox instance.
*/
type SandboxType = 'iframe' | 'worker';
/**
* Tracked sandbox instance with its bridge and metadata.
*/
interface SandboxInstance {
/** Unique ID for this sandbox instance */
sandboxId: string;
/** Extension ID this sandbox belongs to */
extensionId: string;
/** Type of sandbox (iframe or worker) */
type: SandboxType;
/** The SandboxBridge for host-side communication (iframe only) */
bridge: SandboxBridge | null;
/** The WorkerSandbox instance (worker only) */
workerSandbox: WorkerSandbox | null;
/** The host API implementation */
hostImpl: SandboxedExtensionHostImpl;
/** The iframe element (if rendered) */
iframe: HTMLIFrameElement | null;
/** Whether the sandbox is ready */
isReady: boolean;
/** Sandbox configuration */
config: SandboxConfig;
/** Pending command queue (for commands sent before ready) */
pendingCommands: Array<{ command: string; args: unknown[] }>;
}
/**
* Callback for when a sandbox becomes ready.
*/
type SandboxReadyCallback = (sandboxId: string, extensionId: string) => void;
/**
* SandboxManager singleton for managing all sandbox instances.
*
* @remarks
* This manager handles:
* - Creating and tracking sandbox instances
* - Dispatching commands to the correct sandbox
* - Managing sandbox lifecycle (ready state, cleanup)
*/
class SandboxManager {
private static instance: SandboxManager;
/** All sandbox instances by sandbox ID */
private sandboxes: Map<string, SandboxInstance> = new Map();
/** Map from extension ID to sandbox IDs (an extension can have multiple sandboxes) */
private extensionToSandboxes: Map<string, Set<string>> = new Map();
/** Callbacks for sandbox ready events */
private readyCallbacks: Set<SandboxReadyCallback> = new Set();
private constructor() {
// Private constructor for singleton
}
/**
* Get the singleton instance.
*/
public static getInstance(): SandboxManager {
if (!SandboxManager.instance) {
SandboxManager.instance = new SandboxManager();
}
return SandboxManager.instance;
}
/**
* Create a new iframe sandbox instance for an extension.
*
* @param extensionId - The extension ID
* @param config - Sandbox configuration
* @returns The sandbox ID for the new instance
*/
public createSandbox(extensionId: string, config: SandboxConfig): string {
const sandboxId = `sandbox-${extensionId}-${nanoid(8)}`;
const bridge = new SandboxBridge({
bridgeId: sandboxId,
extensionId,
permissions: config.permissions ?? [],
});
const hostImpl = new SandboxedExtensionHostImpl(
extensionId,
config.permissions ?? [],
);
// Set up the API handler
bridge.onApiCall(async (method: string, args: unknown[]) =>
hostImpl.handleApiCall(method, args),
);
const instance: SandboxInstance = {
sandboxId,
extensionId,
type: 'iframe',
bridge,
workerSandbox: null,
hostImpl,
iframe: null,
isReady: false,
config,
pendingCommands: [],
};
this.sandboxes.set(sandboxId, instance);
// Track extension -> sandbox mapping
if (!this.extensionToSandboxes.has(extensionId)) {
this.extensionToSandboxes.set(extensionId, new Set());
}
this.extensionToSandboxes.get(extensionId)!.add(sandboxId);
logging.info(`SandboxManager: Created iframe sandbox ${sandboxId} for ${extensionId}`);
return sandboxId;
}
/**
* Create a new worker sandbox instance for a command-only extension.
*
* @param extensionId - The extension ID
* @param extensionName - The extension name
* @param code - The extension's JavaScript code
* @param config - Sandbox configuration
* @returns The sandbox ID for the new instance
*/
public async createWorkerSandbox(
extensionId: string,
extensionName: string,
code: string,
config: SandboxConfig,
): Promise<string> {
const sandboxId = `worker-${extensionId}-${nanoid(8)}`;
const hostImpl = new SandboxedExtensionHostImpl(
extensionId,
config.permissions ?? [],
);
const workerSandbox = new WorkerSandbox({
sandboxId,
extensionId,
extensionName,
code,
config,
});
const instance: SandboxInstance = {
sandboxId,
extensionId,
type: 'worker',
bridge: null,
workerSandbox,
hostImpl,
iframe: null,
isReady: false,
config,
pendingCommands: [],
};
this.sandboxes.set(sandboxId, instance);
// Track extension -> sandbox mapping
if (!this.extensionToSandboxes.has(extensionId)) {
this.extensionToSandboxes.set(extensionId, new Set());
}
this.extensionToSandboxes.get(extensionId)!.add(sandboxId);
// Set up ready callback
workerSandbox.onReady(() => {
this.handleSandboxReady(sandboxId);
});
// Initialize the worker
await workerSandbox.initialize();
logging.info(`SandboxManager: Created worker sandbox ${sandboxId} for ${extensionId}`);
return sandboxId;
}
/**
* Connect an iframe sandbox to its iframe element.
*
* @param sandboxId - The sandbox ID
* @param iframe - The iframe element
*/
public connectSandbox(sandboxId: string, iframe: HTMLIFrameElement): void {
const instance = this.sandboxes.get(sandboxId);
if (!instance) {
logging.error(`SandboxManager: Sandbox ${sandboxId} not found`);
return;
}
if (instance.type !== 'iframe' || !instance.bridge) {
logging.error(`SandboxManager: Sandbox ${sandboxId} is not an iframe sandbox`);
return;
}
if (!iframe.contentWindow) {
logging.error(`SandboxManager: Iframe has no contentWindow`);
return;
}
instance.iframe = iframe;
instance.bridge.connect(iframe.contentWindow, () => {
this.handleSandboxReady(sandboxId);
});
logging.info(`SandboxManager: Connected iframe sandbox ${sandboxId}`);
}
/**
* Handle sandbox ready event.
*/
private handleSandboxReady(sandboxId: string): void {
const instance = this.sandboxes.get(sandboxId);
if (!instance) {
return;
}
instance.isReady = true;
// Process pending commands
for (const { command, args } of instance.pendingCommands) {
this.dispatchCommandToSandbox(sandboxId, command, args);
}
instance.pendingCommands = [];
// Notify callbacks
for (const callback of this.readyCallbacks) {
try {
callback(sandboxId, instance.extensionId);
} catch (error) {
logging.error('Error in sandbox ready callback:', error);
}
}
logging.info(`SandboxManager: Sandbox ${sandboxId} is ready`);
}
/**
* Dispatch a command to a specific sandbox.
*
* @param sandboxId - The sandbox ID
* @param command - The command name
* @param args - Command arguments
*/
public dispatchCommandToSandbox(
sandboxId: string,
command: string,
args: unknown[] = [],
): void {
const instance = this.sandboxes.get(sandboxId);
if (!instance) {
logging.error(`SandboxManager: Sandbox ${sandboxId} not found`);
return;
}
if (!instance.isReady) {
// Queue the command for when the sandbox is ready
instance.pendingCommands.push({ command, args });
logging.info(`SandboxManager: Queued command ${command} for ${sandboxId}`);
return;
}
// Send command to the sandbox based on type
if (instance.type === 'worker' && instance.workerSandbox) {
instance.workerSandbox.dispatchCommand(command, args);
} else if (instance.type === 'iframe' && instance.bridge) {
instance.bridge.emitEvent('command', { command, args });
} else {
logging.error(`SandboxManager: Cannot dispatch to ${sandboxId} - invalid state`);
return;
}
logging.info(`SandboxManager: Dispatched command ${command} to ${sandboxId}`);
}
/**
* Dispatch a command to all sandboxes of an extension.
*
* @param extensionId - The extension ID
* @param command - The command name
* @param args - Command arguments
*/
public dispatchCommandToExtension(
extensionId: string,
command: string,
args: unknown[] = [],
): void {
const sandboxIds = this.extensionToSandboxes.get(extensionId);
if (!sandboxIds || sandboxIds.size === 0) {
logging.warn(
`SandboxManager: No sandboxes found for extension ${extensionId}`,
);
return;
}
// Dispatch to all sandboxes of this extension
for (const sandboxId of sandboxIds) {
this.dispatchCommandToSandbox(sandboxId, command, args);
}
}
/**
* Get a sandbox instance by ID.
*/
public getSandbox(sandboxId: string): SandboxInstance | undefined {
return this.sandboxes.get(sandboxId);
}
/**
* Get all sandbox IDs for an extension.
*/
public getSandboxesForExtension(extensionId: string): string[] {
const sandboxIds = this.extensionToSandboxes.get(extensionId);
return sandboxIds ? Array.from(sandboxIds) : [];
}
/**
* Check if an extension has any ready sandboxes.
*/
public hasReadySandbox(extensionId: string): boolean {
const sandboxIds = this.extensionToSandboxes.get(extensionId);
if (!sandboxIds) {
return false;
}
for (const sandboxId of sandboxIds) {
const instance = this.sandboxes.get(sandboxId);
if (instance?.isReady) {
return true;
}
}
return false;
}
/**
* Register a callback for sandbox ready events.
*/
public onSandboxReady(callback: SandboxReadyCallback): () => void {
this.readyCallbacks.add(callback);
return () => this.readyCallbacks.delete(callback);
}
/**
* Dispose of a sandbox instance.
*/
public disposeSandbox(sandboxId: string): void {
const instance = this.sandboxes.get(sandboxId);
if (!instance) {
return;
}
// Dispose based on sandbox type
if (instance.type === 'worker' && instance.workerSandbox) {
instance.workerSandbox.dispose();
} else if (instance.type === 'iframe' && instance.bridge) {
instance.bridge.disconnect();
}
this.sandboxes.delete(sandboxId);
const extensionSandboxes = this.extensionToSandboxes.get(instance.extensionId);
if (extensionSandboxes) {
extensionSandboxes.delete(sandboxId);
if (extensionSandboxes.size === 0) {
this.extensionToSandboxes.delete(instance.extensionId);
}
}
logging.info(`SandboxManager: Disposed sandbox ${sandboxId}`);
}
/**
* Dispose of all sandboxes for an extension.
*/
public disposeExtensionSandboxes(extensionId: string): void {
const sandboxIds = this.extensionToSandboxes.get(extensionId);
if (!sandboxIds) {
return;
}
for (const sandboxId of Array.from(sandboxIds)) {
this.disposeSandbox(sandboxId);
}
}
/**
* Dispose of all sandboxes.
*/
public disposeAll(): void {
for (const sandboxId of Array.from(this.sandboxes.keys())) {
this.disposeSandbox(sandboxId);
}
this.readyCallbacks.clear();
}
}
export default SandboxManager;
export { SandboxManager };
export type { SandboxInstance };

View File

@@ -0,0 +1,242 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Test harness for sandboxed extensions.
*
* This component provides a way to test sandboxed extensions locally
* without needing the full Superset backend.
*/
import { useCallback, useRef, useState } from 'react';
import { styled } from '@apache-superset/core/ui';
import { SandboxedExtensionRenderer } from './SandboxedExtensionRenderer';
import { SandboxManager } from './SandboxManager';
import { SandboxConfig, SandboxError } from './types';
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100vh;
padding: 16px;
gap: 16px;
`;
const Header = styled.div`
display: flex;
gap: 16px;
align-items: center;
`;
const SandboxContainer = styled.div`
flex: 1;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
`;
const LogContainer = styled.div`
height: 200px;
border: 1px solid #ccc;
border-radius: 4px;
overflow-y: auto;
padding: 8px;
font-family: monospace;
font-size: 12px;
background: #f5f5f5;
`;
const LogEntry = styled.div<{ type: 'info' | 'error' | 'success' }>`
color: ${({ type }) => {
switch (type) {
case 'error':
return '#d32f2f';
case 'success':
return '#388e3c';
default:
return '#333';
}
}};
margin-bottom: 4px;
`;
interface LogItem {
id: number;
type: 'info' | 'error' | 'success';
message: string;
timestamp: Date;
}
/**
* Test extension code - a simple parquet export simulation.
*/
const TEST_EXTENSION_CODE = `
// Simple test extension
(function() {
const { superset } = window;
if (!superset) {
console.error('Superset API not available');
return;
}
// Register the export command
superset.commands.register('test.export', async () => {
console.log('Export command triggered');
try {
// Get current SQL Lab tab
const tab = await superset.sqlLab.getCurrentTab();
console.log('Current tab:', tab);
if (!tab) {
superset.ui.showNotification('No active tab found', 'warning');
return;
}
if (!tab.sql) {
superset.ui.showNotification('No SQL to export', 'warning');
return;
}
superset.ui.showNotification('Export triggered for: ' + tab.title, 'success');
// Simulate download
const csvContent = 'col1,col2\\n1,2\\n3,4';
superset.utils.downloadFile(btoa(csvContent), 'export.csv');
} catch (error) {
console.error('Export error:', error);
superset.ui.showNotification('Export failed: ' + error.message, 'error');
}
});
// Register a greeting command for testing
superset.commands.register('test.greet', (name) => {
superset.ui.showNotification('Hello, ' + (name || 'World') + '!', 'info');
});
console.log('Test extension activated');
})();
`;
/**
* Test harness for sandboxed extensions.
*/
export function SandboxTestHarness() {
const [logs, setLogs] = useState<LogItem[]>([]);
const [isReady, setIsReady] = useState(false);
const logIdRef = useRef(0);
const addLog = useCallback(
(type: 'info' | 'error' | 'success', message: string) => {
logIdRef.current += 1;
const newId = logIdRef.current;
setLogs(current => [
...current,
{ id: newId, type, message, timestamp: new Date() },
]);
},
[],
);
const config: SandboxConfig = {
trustLevel: 'iframe',
permissions: [
'sqllab:read',
'notification:show',
'download:file',
'clipboard:write',
],
};
const handleReady = useCallback(() => {
setIsReady(true);
addLog('success', 'Sandbox is ready');
}, [addLog]);
const handleError = useCallback(
(error: SandboxError) => {
addLog('error', `Sandbox error: ${error.code} - ${error.message}`);
},
[addLog],
);
const executeCommand = useCallback(
(command: string, ...args: unknown[]) => {
addLog('info', `Executing command: ${command}`);
SandboxManager.getInstance().dispatchCommandToExtension(
'test-extension',
command,
args,
);
},
[addLog],
);
return (
<Container>
<h2>Sandbox Test Harness</h2>
<Header>
<button
type="button"
onClick={() => executeCommand('test.export')}
disabled={!isReady}
>
Trigger Export Command
</button>
<button
type="button"
onClick={() => executeCommand('test.greet', 'Developer')}
disabled={!isReady}
>
Trigger Greet Command
</button>
<span>
Status: <strong>{isReady ? '✓ Ready' : '⏳ Loading...'}</strong>
</span>
</Header>
<SandboxContainer>
<SandboxedExtensionRenderer
extensionId="test-extension"
extensionName="Test Extension"
config={config}
inlineContent={`<script>${TEST_EXTENSION_CODE}</script>`}
onReady={handleReady}
onError={handleError}
style={{ width: '100%', height: '100%' }}
/>
</SandboxContainer>
<h3>Logs</h3>
<LogContainer>
{logs.map(log => (
<LogEntry key={log.id} type={log.type}>
[{log.timestamp.toISOString().split('T')[1].slice(0, 8)}] {log.message}
</LogEntry>
))}
{logs.length === 0 && <span>No logs yet...</span>}
</LogContainer>
</Container>
);
}
export default SandboxTestHarness;

View File

@@ -0,0 +1,249 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SandboxedExtensionHostImpl } from './SandboxedExtensionHost';
// Mock logging
jest.mock('@apache-superset/core', () => ({
logging: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
// Mock Redux store
const mockStore = {
getState: jest.fn(),
};
beforeEach(() => {
(window as unknown as { __REDUX_STORE__: typeof mockStore }).__REDUX_STORE__ =
mockStore;
});
afterEach(() => {
jest.clearAllMocks();
delete (window as unknown as { __REDUX_STORE__?: typeof mockStore })
.__REDUX_STORE__;
});
describe('SandboxedExtensionHostImpl', () => {
test('hasPermission returns true for granted permissions', () => {
const host = new SandboxedExtensionHostImpl('test-extension', [
'sqllab:read',
'notification:show',
]);
expect(host.hasPermission('sqllab:read')).toBe(true);
expect(host.hasPermission('notification:show')).toBe(true);
});
test('hasPermission returns false for denied permissions', () => {
const host = new SandboxedExtensionHostImpl('test-extension', [
'sqllab:read',
]);
expect(host.hasPermission('sqllab:execute')).toBe(false);
expect(host.hasPermission('dashboard:write')).toBe(false);
});
test('handleApiCall rejects when permission denied', async () => {
const host = new SandboxedExtensionHostImpl('test-extension', []);
await expect(
host.handleApiCall('sqlLab.getCurrentTab', []),
).rejects.toEqual({
code: 'PERMISSION_DENIED',
message: "Permission 'sqllab:read' required for 'sqlLab.getCurrentTab'",
});
});
test('handleApiCall rejects for unknown namespace', async () => {
const host = new SandboxedExtensionHostImpl('test-extension', []);
await expect(host.handleApiCall('unknown.method', [])).rejects.toEqual({
code: 'METHOD_NOT_FOUND',
message: 'Unknown API namespace: unknown',
});
});
describe('sqlLab API', () => {
test('getCurrentTab returns null when no state', async () => {
mockStore.getState.mockReturnValue({});
const host = new SandboxedExtensionHostImpl('test-extension', [
'sqllab:read',
]);
const result = await host.handleApiCall('sqlLab.getCurrentTab', []);
expect(result).toBeNull();
});
test('getCurrentTab returns active tab', async () => {
mockStore.getState.mockReturnValue({
sqlLab: {
queryEditors: [
{
id: 'tab-1',
title: 'Test Query',
dbId: 1,
catalog: 'main',
schema: 'public',
sql: 'SELECT 1',
},
],
tabHistory: ['tab-1'],
},
});
const host = new SandboxedExtensionHostImpl('test-extension', [
'sqllab:read',
]);
const result = await host.handleApiCall('sqlLab.getCurrentTab', []);
expect(result).toEqual({
id: 'tab-1',
title: 'Test Query',
databaseId: 1,
catalog: 'main',
schema: 'public',
sql: 'SELECT 1',
});
});
test('getQueryResults rejects without queryId', async () => {
const host = new SandboxedExtensionHostImpl('test-extension', [
'sqllab:read',
]);
await expect(host.handleApiCall('sqlLab.getQueryResults', [])).rejects.toEqual({
code: 'INVALID_ARGUMENT',
message: 'Query ID is required',
});
});
});
describe('user API', () => {
test('getCurrentUser returns user info', async () => {
mockStore.getState.mockReturnValue({
user: {
userId: 1,
username: 'admin',
firstName: 'Admin',
lastName: 'User',
email: 'admin@example.com',
roles: { Admin: true },
},
});
const host = new SandboxedExtensionHostImpl('test-extension', [
'user:read',
]);
const result = await host.handleApiCall('user.getCurrentUser', []);
expect(result).toEqual({
id: 1,
username: 'admin',
firstName: 'Admin',
lastName: 'User',
email: 'admin@example.com',
roles: ['Admin'],
});
});
test('getCurrentUser rejects when no user', async () => {
mockStore.getState.mockReturnValue({});
const host = new SandboxedExtensionHostImpl('test-extension', [
'user:read',
]);
await expect(host.handleApiCall('user.getCurrentUser', [])).rejects.toEqual({
code: 'NO_USER',
message: 'No user information available',
});
});
});
describe('chart API', () => {
test('getData rejects for non-number chartId', async () => {
const host = new SandboxedExtensionHostImpl('test-extension', [
'chart:read',
]);
await expect(host.handleApiCall('chart.getData', ['not-a-number'])).rejects.toEqual({
code: 'INVALID_ARGUMENT',
message: 'Chart ID must be a number',
});
});
test('getData returns chart data', async () => {
mockStore.getState.mockReturnValue({
charts: {
123: {
queriesResponse: [
{
data: [{ col1: 'value1' }],
colnames: ['col1'],
},
],
},
},
});
const host = new SandboxedExtensionHostImpl('test-extension', [
'chart:read',
]);
const result = await host.handleApiCall('chart.getData', [123]);
expect(result).toEqual({
chartId: 123,
data: [{ col1: 'value1' }],
columns: ['col1'],
});
});
});
describe('utils API', () => {
test('getCSRFToken does not require permission', async () => {
const host = new SandboxedExtensionHostImpl('test-extension', []);
// Mock the meta tag
document.head.innerHTML = '<meta name="csrf_token" content="test-token">';
const result = await host.handleApiCall('utils.getCSRFToken', []);
expect(result).toBe('test-token');
document.head.innerHTML = '';
});
test('copyToClipboard requires permission', async () => {
const host = new SandboxedExtensionHostImpl('test-extension', []);
await expect(
host.handleApiCall('utils.copyToClipboard', ['test']),
).rejects.toEqual({
code: 'PERMISSION_DENIED',
message:
"Permission 'clipboard:write' required for 'utils.copyToClipboard'",
});
});
});
});

View File

@@ -0,0 +1,518 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host-side implementation of the sandboxed extension API.
*
* This module provides the actual implementations for API methods that
* sandboxed extensions can call through the SandboxBridge. It enforces
* permission checks and provides safe access to Superset functionality.
*/
import { logging } from '@apache-superset/core';
import {
SandboxPermission,
SandboxError,
SQLLabTab,
QueryResult,
DashboardContext,
DashboardFilter,
ChartData,
UserInfo,
ModalConfig,
ModalResult,
} from './types';
/**
* Permission requirement for each API method.
*/
const PERMISSION_MAP: Record<string, SandboxPermission | null> = {
// SQL Lab
'sqlLab.getCurrentTab': 'sqllab:read',
'sqlLab.getQueryResults': 'sqllab:read',
'sqlLab.executeQuery': 'sqllab:execute',
// Dashboard
'dashboard.getContext': 'dashboard:read',
'dashboard.getFilters': 'dashboard:read',
'dashboard.setFilter': 'dashboard:write',
// Chart
'chart.getData': 'chart:read',
'chart.refresh': 'chart:write',
// User
'user.getCurrentUser': 'user:read',
// UI
'ui.showNotification': 'notification:show',
'ui.openModal': 'modal:open',
'ui.navigateTo': 'navigation:redirect',
// Utils
'utils.copyToClipboard': 'clipboard:write',
'utils.downloadFile': 'download:file',
'utils.getCSRFToken': null, // No permission required
};
/**
* Implementation of the host API for sandboxed extensions.
*
* @remarks
* This class provides the actual functionality for API calls made by
* sandboxed extensions. Each method checks permissions before executing
* and returns results in a format safe for postMessage serialization.
*/
export class SandboxedExtensionHostImpl {
// @ts-expect-error Reserved for future use in logging/auditing
private _extensionId: string;
private permissions: Set<SandboxPermission>;
constructor(extensionId: string, permissions: SandboxPermission[]) {
this._extensionId = extensionId;
this.permissions = new Set(permissions);
}
/**
* Check if the extension has a specific permission.
*/
hasPermission(permission: SandboxPermission): boolean {
return this.permissions.has(permission);
}
/**
* Handle an API call from the sandboxed extension.
*
* @param method - The method name (e.g., 'sqlLab.getCurrentTab')
* @param args - Arguments passed to the method
* @returns The result of the API call
* @throws SandboxError if permission denied or method not found
*/
async handleApiCall(method: string, args: unknown[]): Promise<unknown> {
// Check permission
const requiredPermission = PERMISSION_MAP[method];
if (requiredPermission !== null && requiredPermission !== undefined) {
if (!this.hasPermission(requiredPermission)) {
throw {
code: 'PERMISSION_DENIED',
message: `Permission '${requiredPermission}' required for '${method}'`,
} as SandboxError;
}
}
// Route to the appropriate handler
const [namespace, methodName] = method.split('.');
switch (namespace) {
case 'sqlLab':
return this.handleSqlLabCall(methodName, args);
case 'dashboard':
return this.handleDashboardCall(methodName, args);
case 'chart':
return this.handleChartCall(methodName, args);
case 'user':
return this.handleUserCall(methodName, args);
case 'ui':
return this.handleUICall(methodName, args);
case 'utils':
return this.handleUtilsCall(methodName, args);
default:
throw {
code: 'METHOD_NOT_FOUND',
message: `Unknown API namespace: ${namespace}`,
} as SandboxError;
}
}
/**
* Handle SQL Lab API calls.
*/
private async handleSqlLabCall(
method: string,
args: unknown[],
): Promise<unknown> {
switch (method) {
case 'getCurrentTab': {
// Access the SQL Lab state from the Redux store
// This is a simplified implementation - in production, this would
// interface with the actual SQL Lab state
const state = this.getReduxState();
const sqlLab = state?.sqlLab as Record<string, unknown> | undefined;
if (!sqlLab) {
return null;
}
const queryEditors = sqlLab.queryEditors as Array<Record<string, unknown>> | undefined;
const tabHistory = sqlLab.tabHistory as string[] | undefined;
const activeTab = queryEditors?.find(
(qe: Record<string, unknown>) => qe.id === tabHistory?.slice(-1)[0],
);
if (!activeTab) {
return null;
}
return {
id: activeTab.id as string,
title: (activeTab.title as string) || 'Untitled',
databaseId: (activeTab.dbId as number) ?? null,
catalog: (activeTab.catalog as string) ?? null,
schema: (activeTab.schema as string) ?? null,
sql: (activeTab.sql as string) ?? '',
} as SQLLabTab;
}
case 'getQueryResults': {
const queryId = args[0] as string;
if (!queryId) {
throw {
code: 'INVALID_ARGUMENT',
message: 'Query ID is required',
} as SandboxError;
}
const state = this.getReduxState();
const sqlLab = state?.sqlLab as Record<string, unknown> | undefined;
const queries = sqlLab?.queries as Record<string, Record<string, unknown>> | undefined;
const query = queries?.[queryId];
if (!query) {
return null;
}
const results = query.results as Record<string, unknown> | undefined;
const columns = results?.columns as Array<Record<string, unknown>> | undefined;
return {
queryId: query.id as string,
status: query.state as string,
data: (results?.data as Record<string, unknown>[]) ?? null,
columns: columns?.map((c: Record<string, unknown>) => c.name as string) ?? [],
error: (query.errorMessage as string) ?? null,
} as QueryResult;
}
default:
throw {
code: 'METHOD_NOT_FOUND',
message: `Unknown sqlLab method: ${method}`,
} as SandboxError;
}
}
/**
* Handle Dashboard API calls.
*/
private async handleDashboardCall(
method: string,
args: unknown[],
): Promise<unknown> {
switch (method) {
case 'getContext': {
const state = this.getReduxState();
const dashboard = state?.dashboardInfo as Record<string, unknown> | undefined;
if (!dashboard) {
return null;
}
return {
id: dashboard.id as number,
title: dashboard.dash_edit_perm
? (dashboard.dashboard_title as string)
: 'Dashboard',
slug: (dashboard.slug as string) ?? null,
} as DashboardContext;
}
case 'getFilters': {
const state = this.getReduxState();
const nativeFilters = state?.nativeFilters as Record<string, unknown> | undefined;
const filters = nativeFilters?.filters as Record<string, Record<string, unknown>> | undefined;
if (!filters) {
return [];
}
return Object.entries(filters).map(
([id, filter]: [string, Record<string, unknown>]) => {
const targets = filter.targets as Array<Record<string, unknown>> | undefined;
const column = targets?.[0]?.column as Record<string, unknown> | undefined;
return {
id,
column: (column?.name as string) ?? '',
value: filter.currentValue ?? null,
};
},
) as DashboardFilter[];
}
default:
throw {
code: 'METHOD_NOT_FOUND',
message: `Unknown dashboard method: ${method}`,
} as SandboxError;
}
}
/**
* Handle Chart API calls.
*/
private async handleChartCall(
method: string,
args: unknown[],
): Promise<unknown> {
switch (method) {
case 'getData': {
const chartId = args[0] as number;
if (typeof chartId !== 'number') {
throw {
code: 'INVALID_ARGUMENT',
message: 'Chart ID must be a number',
} as SandboxError;
}
const state = this.getReduxState();
const charts = state?.charts as Record<number, { queriesResponse?: Array<{ data?: unknown[]; colnames?: string[] }> }> | undefined;
const chartState = charts?.[chartId];
if (!chartState?.queriesResponse?.[0]) {
return null;
}
const queryResponse = chartState.queriesResponse[0];
return {
chartId,
data: queryResponse.data ?? [],
columns: queryResponse.colnames ?? [],
} as ChartData;
}
default:
throw {
code: 'METHOD_NOT_FOUND',
message: `Unknown chart method: ${method}`,
} as SandboxError;
}
}
/**
* Handle User API calls.
*/
private async handleUserCall(
method: string,
args: unknown[],
): Promise<unknown> {
switch (method) {
case 'getCurrentUser': {
const state = this.getReduxState();
const user = state?.user as Record<string, unknown> | undefined;
if (!user) {
throw {
code: 'NO_USER',
message: 'No user information available',
} as SandboxError;
}
const roles = user.roles as Record<string, unknown> | undefined;
return {
id: user.userId as number,
username: user.username as string,
firstName: (user.firstName as string) ?? '',
lastName: (user.lastName as string) ?? '',
email: (user.email as string) ?? '',
roles: Object.keys(roles ?? {}),
} as UserInfo;
}
default:
throw {
code: 'METHOD_NOT_FOUND',
message: `Unknown user method: ${method}`,
} as SandboxError;
}
}
/**
* Handle UI API calls.
*/
private async handleUICall(
method: string,
args: unknown[],
): Promise<unknown> {
switch (method) {
case 'showNotification': {
const [message, type] = args as [
string,
'info' | 'success' | 'warning' | 'error',
];
// Use the browser's notification API or a toast library
// For now, we'll dispatch to the global message system
const { message: antdMessage } = await import('antd');
antdMessage[type || 'info'](message);
return undefined;
}
case 'openModal': {
const config = args[0] as ModalConfig;
// Use antd's Modal.confirm for simple modals
const { Modal } = await import('antd');
return new Promise<ModalResult>(resolve => {
const modalType = config.type === 'confirm' ? 'confirm' : config.type || 'info';
const modalMethod = Modal[modalType as keyof typeof Modal] as (
config: Record<string, unknown>,
) => void;
modalMethod({
title: config.title,
content: config.content,
okText: config.okText ?? 'OK',
cancelText: config.cancelText ?? 'Cancel',
onOk: () => resolve({ confirmed: true }),
onCancel: () => resolve({ confirmed: false }),
});
});
}
case 'navigateTo': {
const path = args[0] as string;
// Validate the path is a relative URL within Superset
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
throw {
code: 'INVALID_PATH',
message: 'Only relative paths are allowed',
} as SandboxError;
}
// Use React Router's history if available, otherwise window.location
window.location.href = path;
return undefined;
}
default:
throw {
code: 'METHOD_NOT_FOUND',
message: `Unknown ui method: ${method}`,
} as SandboxError;
}
}
/**
* Handle Utils API calls.
*/
private async handleUtilsCall(
method: string,
args: unknown[],
): Promise<unknown> {
switch (method) {
case 'copyToClipboard': {
const text = args[0] as string;
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
logging.warn('Failed to copy to clipboard:', err);
return false;
}
}
case 'downloadFile': {
const [data, filename] = args as [Blob | string, string];
// Handle both Blob and base64 string data
let blob: Blob;
if (typeof data === 'string') {
// Assume base64 encoded data
const byteCharacters = atob(data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i += 1) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
blob = new Blob([byteArray]);
} else {
blob = data;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return undefined;
}
case 'getCSRFToken': {
// Get CSRF token from cookie or meta tag
const csrfToken =
document
.querySelector('meta[name="csrf_token"]')
?.getAttribute('content') ??
document.cookie
.split('; ')
.find(row => row.startsWith('csrf_access_token='))
?.split('=')[1] ??
'';
return csrfToken;
}
default:
throw {
code: 'METHOD_NOT_FOUND',
message: `Unknown utils method: ${method}`,
} as SandboxError;
}
}
/**
* Get the Redux state.
*
* @remarks
* This accesses the global Redux store. In a production implementation,
* this should be injected via dependency injection.
*/
private getReduxState(): Record<string, unknown> | null {
// Access the global Redux store
// This is a simplified approach - in production, use proper DI
const windowWithStore = window as unknown as {
__REDUX_STORE__?: { getState: () => Record<string, unknown> };
};
const store = windowWithStore.__REDUX_STORE__;
if (store && typeof store.getState === 'function') {
return store.getState();
}
return null;
}
}
export default SandboxedExtensionHostImpl;

View File

@@ -0,0 +1,226 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview SandboxedExtensionRenderer component.
*
* This component renders a sandboxed extension in an iframe with proper
* lifecycle management and bridge connection.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { logging } from '@apache-superset/core';
import { IframeSandbox } from './IframeSandbox';
import { SandboxManager } from './SandboxManager';
import { SandboxConfig, SandboxError } from './types';
/**
* Props for the SandboxedExtensionRenderer component.
*/
interface SandboxedExtensionRendererProps {
/** Extension ID to render */
extensionId: string;
/** Extension name for display */
extensionName: string;
/** Sandbox configuration */
config: SandboxConfig;
/** URL to the extension's entry point */
entryUrl?: string;
/** Inline HTML content (alternative to entryUrl) */
inlineContent?: string;
/** Callback when sandbox is ready */
onReady?: () => void;
/** Callback when sandbox encounters an error */
onError?: (error: SandboxError) => void;
/** Additional CSS class name */
className?: string;
/** Custom styles */
style?: React.CSSProperties;
}
/**
* Renders a sandboxed extension with proper lifecycle management.
*
* @remarks
* This component:
* 1. Creates a sandbox instance via SandboxManager
* 2. Renders the IframeSandbox component
* 3. Connects the sandbox when the iframe is ready
* 4. Cleans up on unmount
*
* @example
* ```tsx
* <SandboxedExtensionRenderer
* extensionId="sqllab_parquet_sandboxed"
* extensionName="Export to Parquet"
* config={{
* trustLevel: 'iframe',
* permissions: ['sqllab:read', 'notification:show'],
* }}
* onReady={() => console.log('Extension ready')}
* />
* ```
*/
export function SandboxedExtensionRenderer({
extensionId,
extensionName,
config,
entryUrl,
inlineContent,
onReady,
onError,
className,
style,
}: SandboxedExtensionRendererProps) {
const [sandboxId, setSandboxId] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const sandboxManager = SandboxManager.getInstance();
// Create sandbox instance on mount
useEffect(() => {
const newSandboxId = sandboxManager.createSandbox(extensionId, config);
setSandboxId(newSandboxId);
logging.info(
`SandboxedExtensionRenderer: Created sandbox ${newSandboxId} for ${extensionId}`,
);
// Cleanup on unmount
return () => {
sandboxManager.disposeSandbox(newSandboxId);
};
}, [extensionId, config, sandboxManager]);
// Handle iframe ready
const handleReady = useCallback(() => {
if (sandboxId && iframeRef.current) {
sandboxManager.connectSandbox(sandboxId, iframeRef.current);
}
onReady?.();
}, [sandboxId, sandboxManager, onReady]);
// Handle errors
const handleError = useCallback(
(error: SandboxError) => {
logging.error(`Sandbox error for ${extensionId}:`, error);
onError?.(error);
},
[extensionId, onError],
);
// Store iframe ref when IframeSandbox renders
const handleIframeRef = useCallback((iframe: HTMLIFrameElement | null) => {
iframeRef.current = iframe;
}, []);
if (!sandboxId) {
return null;
}
return (
<IframeSandboxWithRef
ref={handleIframeRef}
sandboxId={sandboxId}
extensionId={extensionId}
extensionName={extensionName}
entryUrl={entryUrl}
inlineContent={inlineContent}
config={config}
onReady={handleReady}
onError={handleError}
className={className}
style={style}
/>
);
}
/**
* Wrapper to expose iframe ref from IframeSandbox.
*
* Since IframeSandbox manages its own ref internally, we need a way to
* get access to the iframe element for the SandboxManager connection.
*/
import { forwardRef, useImperativeHandle } from 'react';
interface IframeSandboxWithRefProps {
sandboxId: string;
extensionId: string;
extensionName: string;
entryUrl?: string;
inlineContent?: string;
config: SandboxConfig;
onReady?: () => void;
onError?: (error: SandboxError) => void;
className?: string;
style?: React.CSSProperties;
}
const IframeSandboxWithRef = forwardRef<
HTMLIFrameElement | null,
IframeSandboxWithRefProps
>(function IframeSandboxWithRef(
{
sandboxId,
extensionId,
extensionName,
entryUrl,
inlineContent,
config,
onReady,
onError,
className,
style,
},
ref,
) {
const internalRef = useRef<HTMLIFrameElement | null>(null);
// Expose the iframe element via the forwarded ref
useImperativeHandle(ref, () => internalRef.current as HTMLIFrameElement, []);
// We need to intercept the onReady to get the iframe ref
const handleReady = useCallback(() => {
// Find the iframe in the DOM (IframeSandbox creates it)
const container = document.querySelector(
`[data-sandbox-id="${sandboxId}"]`,
);
const iframe = container?.querySelector('iframe');
if (iframe) {
internalRef.current = iframe as HTMLIFrameElement;
}
onReady?.();
}, [sandboxId, onReady]);
return (
<div data-sandbox-id={sandboxId} className={className} style={style}>
<IframeSandbox
sandboxId={sandboxId}
extensionId={extensionId}
extensionName={extensionName}
entryUrl={entryUrl}
inlineContent={inlineContent}
config={config}
onReady={handleReady}
onError={onError}
/>
</div>
);
});
export default SandboxedExtensionRenderer;

View File

@@ -0,0 +1,465 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview WASM Sandbox for Tier 3 logic-only extensions.
*
* This module provides a secure execution environment for untrusted JavaScript
* code using QuickJS compiled to WebAssembly. Extensions running in this sandbox
* have no access to the DOM, network, or browser APIs - only explicitly injected
* functions are available.
*
* @remarks
* This sandbox is intended for:
* - Custom data transformations
* - Calculated fields and formatters
* - Data validation rules
* - Custom aggregation functions
*
* It is NOT suitable for extensions that need to render UI.
*/
import { logging } from '@apache-superset/core';
import type {
QuickJSWASMModule,
QuickJSRuntime,
QuickJSContext,
QuickJSHandle,
} from 'quickjs-emscripten';
import {
WASMSandboxConfig,
WASMExecutionResult,
WASMResourceLimits,
} from './types';
/**
* Default resource limits for WASM sandbox execution.
*/
const DEFAULT_RESOURCE_LIMITS: Required<WASMResourceLimits> = {
maxMemory: 10 * 1024 * 1024, // 10MB
maxExecutionTime: 5000, // 5 seconds
maxStackSize: 1000,
};
// Lazy-loaded QuickJS module
let quickJSPromise: Promise<QuickJSWASMModule> | null = null;
/**
* Lazily load the QuickJS WebAssembly module.
*
* @remarks
* This function loads quickjs-emscripten on demand to avoid
* bundling the WASM file when not needed.
*/
async function loadQuickJS(): Promise<QuickJSWASMModule> {
if (!quickJSPromise) {
quickJSPromise = (async () => {
try {
// Dynamic import to avoid bundling when not used
const { getQuickJS } = await import('quickjs-emscripten');
return getQuickJS();
} catch (error) {
logging.error('Failed to load QuickJS:', error);
throw new Error(
'QuickJS WASM module not available. Install quickjs-emscripten to use WASM sandboxing.',
);
}
})();
}
return quickJSPromise;
}
/**
* WASM Sandbox for executing untrusted JavaScript code securely.
*
* @remarks
* This class provides a secure execution environment using QuickJS
* compiled to WebAssembly. Key features:
*
* - Complete isolation from browser APIs
* - Memory limits to prevent DoS
* - Execution time limits
* - Only explicitly injected APIs are available
*
* @example
* ```typescript
* const sandbox = new WASMSandbox({
* sandboxId: 'transformer-1',
* extensionId: 'my-extension',
* code: `
* function transform(data) {
* return data.map(row => ({ ...row, computed: row.a + row.b }));
* }
* `,
* config: { trustLevel: 'wasm' },
* injectedAPIs: {
* formatNumber: (n, decimals) => n.toFixed(decimals),
* },
* });
*
* await sandbox.initialize();
* const result = await sandbox.execute('transform', [[{ a: 1, b: 2 }]]);
* sandbox.dispose();
* ```
*/
export class WASMSandbox {
private config: WASMSandboxConfig;
private runtime: QuickJSRuntime | null = null;
private context: QuickJSContext | null = null;
private resourceLimits: Required<WASMResourceLimits>;
private isInitialized = false;
private executionStartTime = 0;
constructor(config: WASMSandboxConfig) {
this.config = config;
this.resourceLimits = {
...DEFAULT_RESOURCE_LIMITS,
...config.config.resourceLimits,
};
}
/**
* Initialize the sandbox environment.
*
* @remarks
* This loads the QuickJS WASM module, creates a runtime with resource
* limits, and evaluates the extension code.
*/
async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
const QuickJS = await loadQuickJS();
// Create runtime with memory limit
this.runtime = QuickJS.newRuntime();
this.runtime.setMemoryLimit(this.resourceLimits.maxMemory);
// Set up interrupt handler for execution time limit
this.runtime.setInterruptHandler(() => {
const elapsed = Date.now() - this.executionStartTime;
return elapsed > this.resourceLimits.maxExecutionTime;
});
// Create context
this.context = this.runtime.newContext();
// Inject APIs
this.injectAPIs();
// Evaluate the extension code
try {
const result = this.context.evalCode(this.config.code);
if (result.error) {
const errorValue = this.context.dump(result.error);
result.error.dispose();
throw new Error(`Code evaluation failed: ${String(errorValue)}`);
}
result.value.dispose();
} catch (error) {
this.dispose();
throw error;
}
this.isInitialized = true;
logging.info(`WASMSandbox ${this.config.sandboxId} initialized`);
}
/**
* Execute a function defined in the sandboxed code.
*
* @param functionName - Name of the function to call
* @param args - Arguments to pass to the function
* @returns Execution result including timing and memory info
*/
async execute(
functionName: string,
args: unknown[] = [],
): Promise<WASMExecutionResult> {
if (!this.isInitialized || !this.context) {
return {
success: false,
error: {
code: 'NOT_INITIALIZED',
message: 'Sandbox is not initialized',
},
executionTime: 0,
memoryUsed: 0,
};
}
this.executionStartTime = Date.now();
try {
// Get the function from global scope
const fnHandle = this.context.getProp(this.context.global, functionName);
if (this.context.typeof(fnHandle) !== 'function') {
fnHandle.dispose();
return {
success: false,
error: {
code: 'FUNCTION_NOT_FOUND',
message: `Function '${functionName}' not found in sandbox`,
},
executionTime: Date.now() - this.executionStartTime,
memoryUsed: 0,
};
}
fnHandle.dispose();
// Build call expression with JSON-serialized args
const argsJson = JSON.stringify(args);
const callCode = `${functionName}.apply(null, ${argsJson})`;
const resultHandle = this.context.evalCode(callCode);
if (resultHandle.error) {
const errorValue = this.context.dump(resultHandle.error);
resultHandle.error.dispose();
return {
success: false,
error: {
code: 'EXECUTION_ERROR',
message: String(errorValue),
},
executionTime: Date.now() - this.executionStartTime,
memoryUsed: 0,
};
}
const result = this.context.dump(resultHandle.value);
resultHandle.value.dispose();
return {
success: true,
result,
executionTime: Date.now() - this.executionStartTime,
memoryUsed: 0, // QuickJS doesn't expose memory usage directly
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
// Check if it was an interrupt (timeout)
if (errorMessage.includes('interrupted')) {
return {
success: false,
error: {
code: 'TIMEOUT',
message: `Execution exceeded ${this.resourceLimits.maxExecutionTime}ms limit`,
},
executionTime: Date.now() - this.executionStartTime,
memoryUsed: 0,
};
}
return {
success: false,
error: {
code: 'EXECUTION_ERROR',
message: errorMessage,
},
executionTime: Date.now() - this.executionStartTime,
memoryUsed: 0,
};
}
}
/**
* Dispose of the sandbox and free resources.
*/
dispose(): void {
if (this.context) {
this.context.dispose();
this.context = null;
}
if (this.runtime) {
this.runtime.dispose();
this.runtime = null;
}
this.isInitialized = false;
logging.info(`WASMSandbox ${this.config.sandboxId} disposed`);
}
/**
* Check if the sandbox is initialized.
*/
isReady(): boolean {
return this.isInitialized;
}
/**
* Inject safe APIs into the sandbox context.
*/
private injectAPIs(): void {
if (!this.context) {
return;
}
const ctx = this.context;
// Inject custom APIs provided in config
if (this.config.injectedAPIs) {
const apisObj = ctx.newObject();
for (const [name, fn] of Object.entries(this.config.injectedAPIs)) {
if (typeof fn === 'function') {
const wrappedFn = ctx.newFunction(name, (...handles: QuickJSHandle[]) => {
const fnArgs = handles.map(h => ctx.dump(h));
const fnResult = (fn as (...args: unknown[]) => unknown)(...fnArgs);
return this.jsToQuickJS(fnResult);
});
ctx.setProp(apisObj, name, wrappedFn);
wrappedFn.dispose();
}
}
ctx.setProp(ctx.global, 'superset', apisObj);
apisObj.dispose();
}
// Inject safe built-in utilities
this.injectSafeBuiltins();
}
/**
* Inject safe built-in functions.
*
* @remarks
* We only inject functions that are safe and useful for data transformations.
* No network, DOM, or timer APIs are available.
*/
private injectSafeBuiltins(): void {
if (!this.context) {
return;
}
const ctx = this.context;
// console.log for debugging (outputs to host console)
const consoleObj = ctx.newObject();
const logFn = ctx.newFunction('log', (...handles: QuickJSHandle[]) => {
const logArgs = handles.map(h => ctx.dump(h));
logging.info(`[WASMSandbox ${this.config.sandboxId}]`, ...logArgs);
return ctx.undefined;
});
ctx.setProp(consoleObj, 'log', logFn);
logFn.dispose();
const warnFn = ctx.newFunction('warn', (...handles: QuickJSHandle[]) => {
const warnArgs = handles.map(h => ctx.dump(h));
logging.warn(`[WASMSandbox ${this.config.sandboxId}]`, ...warnArgs);
return ctx.undefined;
});
ctx.setProp(consoleObj, 'warn', warnFn);
warnFn.dispose();
const errorFn = ctx.newFunction('error', (...handles: QuickJSHandle[]) => {
const errorArgs = handles.map(h => ctx.dump(h));
logging.error(`[WASMSandbox ${this.config.sandboxId}]`, ...errorArgs);
return ctx.undefined;
});
ctx.setProp(consoleObj, 'error', errorFn);
errorFn.dispose();
ctx.setProp(ctx.global, 'console', consoleObj);
consoleObj.dispose();
}
/**
* Convert a JavaScript value to a QuickJS handle.
*/
private jsToQuickJS(value: unknown): QuickJSHandle {
if (!this.context) {
throw new Error('Context not initialized');
}
const ctx = this.context;
if (value === undefined) {
return ctx.undefined;
}
if (value === null) {
return ctx.null;
}
if (typeof value === 'string') {
return ctx.newString(value);
}
if (typeof value === 'number') {
return ctx.newNumber(value);
}
if (typeof value === 'boolean') {
return value ? ctx.true : ctx.false;
}
if (Array.isArray(value)) {
const arr = ctx.newArray();
value.forEach((item, index) => {
const itemHandle = this.jsToQuickJS(item);
ctx.setProp(arr, index, itemHandle);
itemHandle.dispose();
});
return arr;
}
if (typeof value === 'object') {
const obj = ctx.newObject();
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
const valHandle = this.jsToQuickJS(val);
ctx.setProp(obj, key, valHandle);
valHandle.dispose();
}
return obj;
}
// For unsupported types, convert to string
return ctx.newString(String(value));
}
}
/**
* Factory function to create and initialize a WASM sandbox.
*
* @param config - Sandbox configuration
* @returns Initialized WASMSandbox instance
*/
export async function createWASMSandbox(
config: WASMSandboxConfig,
): Promise<WASMSandbox> {
const sandbox = new WASMSandbox(config);
await sandbox.initialize();
return sandbox;
}
export default WASMSandbox;

View File

@@ -0,0 +1,549 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Web Worker Sandbox for command-only extensions.
*
* This module provides a lightweight sandbox for extensions that don't need
* to render UI. It uses Web Workers for isolation, which is lighter weight
* than iframes but still provides security boundaries.
*
* @remarks
* Web Workers:
* - Run in a separate thread (true parallelism)
* - Have no DOM access (can't render UI)
* - Have no access to the parent's window, document, cookies, etc.
* - Can make network requests (fetch)
* - Communicate via postMessage (same as iframe sandbox)
*
* This is ideal for extensions that:
* - Register command handlers
* - Process data
* - Make API calls
* - But don't need to render any UI
*/
import { logging } from '@apache-superset/core';
import { nanoid } from 'nanoid';
import { SandboxConfig, SandboxError } from './types';
import { SandboxedExtensionHostImpl } from './SandboxedExtensionHost';
/**
* Configuration for creating a WorkerSandbox.
*/
interface WorkerSandboxConfig {
/** Unique ID for this sandbox instance */
sandboxId: string;
/** Extension ID */
extensionId: string;
/** Extension name */
extensionName: string;
/** The extension's JavaScript code to run in the worker */
code: string;
/** Sandbox configuration */
config: SandboxConfig;
}
/**
* Pending request tracker for async responses.
*/
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: SandboxError) => void;
timeout: ReturnType<typeof setTimeout>;
}
/**
* Generate the worker script that includes the bridge client and extension code.
*/
function generateWorkerScript(extensionId: string, extensionCode: string): string {
// The bridge client code for the worker
const bridgeClientCode = `
// Minimal SandboxBridgeClient for Web Worker
class SandboxBridgeClient {
constructor(extensionId) {
this.extensionId = extensionId;
this.pendingRequests = new Map();
this.eventHandlers = new Map();
this.callTimeout = 30000;
}
call(method, args = []) {
return new Promise((resolve, reject) => {
const id = Math.random().toString(36).slice(2);
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject({ code: 'TIMEOUT', message: 'Call timed out' });
}, this.callTimeout);
this.pendingRequests.set(id, { resolve, reject, timeout });
self.postMessage({
type: 'api-call',
id,
extensionId: this.extensionId,
method,
args
});
});
}
on(eventName, handler) {
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, new Set());
}
this.eventHandlers.get(eventName).add(handler);
return () => this.eventHandlers.get(eventName)?.delete(handler);
}
handleMessage(msg) {
if (!msg || msg.extensionId !== this.extensionId) return;
if (msg.type === 'api-response') {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(msg.id);
msg.error ? pending.reject(msg.error) : pending.resolve(msg.result);
}
} else if (msg.type === 'event') {
const handlers = this.eventHandlers.get(msg.eventName);
if (handlers) handlers.forEach(h => { try { h(msg.data); } catch(e) { console.error(e); }});
}
}
sendReady() {
self.postMessage({
type: 'ready',
id: Math.random().toString(36).slice(2),
extensionId: this.extensionId
});
}
}
// Command registry
const commandRegistry = new Map();
// Initialize the bridge
const superset = {
bridge: new SandboxBridgeClient('${extensionId}'),
// Command system
commands: {
register: (command, handler) => {
commandRegistry.set(command, handler);
return () => commandRegistry.delete(command);
},
execute: async (command, ...args) => {
const handler = commandRegistry.get(command);
if (handler) {
return handler(...args);
}
console.warn('Command not found:', command);
return undefined;
},
},
// API wrappers (same as iframe sandbox)
sqlLab: {
getCurrentTab: () => superset.bridge.call('sqlLab.getCurrentTab'),
getQueryResults: (id) => superset.bridge.call('sqlLab.getQueryResults', [id]),
},
dashboard: {
getContext: () => superset.bridge.call('dashboard.getContext'),
getFilters: () => superset.bridge.call('dashboard.getFilters'),
},
chart: {
getData: (id) => superset.bridge.call('chart.getData', [id]),
},
user: {
getCurrentUser: () => superset.bridge.call('user.getCurrentUser'),
},
ui: {
showNotification: (msg, type) => superset.bridge.call('ui.showNotification', [msg, type]),
openModal: (config) => superset.bridge.call('ui.openModal', [config]),
navigateTo: (path) => superset.bridge.call('ui.navigateTo', [path]),
},
utils: {
copyToClipboard: (text) => superset.bridge.call('utils.copyToClipboard', [text]),
downloadFile: (data, filename) => superset.bridge.call('utils.downloadFile', [data, filename]),
getCSRFToken: () => superset.bridge.call('utils.getCSRFToken'),
},
on: (event, handler) => superset.bridge.on(event, handler),
};
// Listen for messages from host
self.addEventListener('message', (event) => {
superset.bridge.handleMessage(event.data);
});
// Listen for command events
superset.on('command', ({ command, args }) => {
superset.commands.execute(command, ...(args || []));
});
// Make available globally in worker scope
self.superset = superset;
// Signal ready
superset.bridge.sendReady();
`;
return `
${bridgeClientCode}
// Extension code
${extensionCode}
// Auto-activate if the extension exports an activate function
if (typeof activate === 'function') {
activate();
}
`;
}
/**
* Web Worker Sandbox for command-only extensions.
*
* @remarks
* This provides a lightweight alternative to iframe sandboxes for extensions
* that don't need to render UI. The worker runs in a separate thread with
* no DOM access, communicating with the host via postMessage.
*
* @example
* ```typescript
* const sandbox = new WorkerSandbox({
* sandboxId: 'worker-1',
* extensionId: 'my-extension',
* extensionName: 'My Extension',
* code: `
* superset.commands.register('my-extension.doSomething', async () => {
* const data = await superset.sqlLab.getCurrentTab();
* superset.ui.showNotification('Got data!', 'success');
* });
* `,
* config: { trustLevel: 'worker', permissions: ['sqllab:read'] },
* });
*
* await sandbox.initialize();
* sandbox.dispatchCommand('my-extension.doSomething');
* ```
*/
export class WorkerSandbox {
private config: WorkerSandboxConfig;
private worker: Worker | null = null;
private hostImpl: SandboxedExtensionHostImpl;
private pendingRequests: Map<string, PendingRequest> = new Map();
private isReady = false;
private readyPromise: Promise<void>;
private readyResolve: (() => void) | null = null;
private onReadyCallback: (() => void) | null = null;
private onErrorCallback: ((error: SandboxError) => void) | null = null;
constructor(config: WorkerSandboxConfig) {
this.config = config;
this.hostImpl = new SandboxedExtensionHostImpl(
config.extensionId,
config.config.permissions ?? [],
);
// Create a promise that resolves when the worker is ready
this.readyPromise = new Promise(resolve => {
this.readyResolve = resolve;
});
}
/**
* Initialize the worker sandbox.
*/
async initialize(): Promise<void> {
if (this.worker) {
logging.warn(`WorkerSandbox ${this.config.sandboxId} already initialized`);
return;
}
try {
// Generate the worker script
const workerScript = generateWorkerScript(
this.config.extensionId,
this.config.code,
);
// Create a Blob URL for the worker
const blob = new Blob([workerScript], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
// Create the worker
this.worker = new Worker(workerUrl);
// Clean up the Blob URL (worker has already loaded it)
URL.revokeObjectURL(workerUrl);
// Set up message handling
this.worker.onmessage = (event: MessageEvent) => {
this.handleMessage(event.data);
};
this.worker.onerror = (event: ErrorEvent) => {
const error: SandboxError = {
code: 'WORKER_ERROR',
message: event.message || 'Worker error',
};
logging.error(`WorkerSandbox ${this.config.sandboxId} error:`, error);
this.onErrorCallback?.(error);
};
logging.info(`WorkerSandbox ${this.config.sandboxId} initialized`);
// Wait for ready signal with timeout
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => {
if (!this.isReady) {
reject(new Error('Worker ready timeout'));
}
}, 10000);
});
await Promise.race([this.readyPromise, timeoutPromise]);
} catch (error) {
const sandboxError: SandboxError = {
code: 'INIT_ERROR',
message: error instanceof Error ? error.message : 'Failed to initialize worker',
};
this.onErrorCallback?.(sandboxError);
throw sandboxError;
}
}
/**
* Handle messages from the worker.
*/
private async handleMessage(message: unknown): Promise<void> {
if (!message || typeof message !== 'object') {
return;
}
const msg = message as {
type: string;
id?: string;
extensionId?: string;
method?: string;
args?: unknown[];
result?: unknown;
error?: SandboxError;
};
// Verify message is from our extension
if (msg.extensionId !== this.config.extensionId) {
return;
}
switch (msg.type) {
case 'ready':
this.isReady = true;
this.readyResolve?.();
this.onReadyCallback?.();
logging.info(`WorkerSandbox ${this.config.sandboxId} ready`);
break;
case 'api-call':
await this.handleApiCall(msg.id!, msg.method!, msg.args ?? []);
break;
case 'api-response':
this.handleApiResponse(msg.id!, msg.result, msg.error);
break;
default:
logging.warn(`Unknown message type from worker: ${msg.type}`);
}
}
/**
* Handle API call from the worker.
*/
private async handleApiCall(
id: string,
method: string,
args: unknown[],
): Promise<void> {
try {
const result = await this.hostImpl.handleApiCall(method, args);
this.sendToWorker({
type: 'api-response',
id,
extensionId: this.config.extensionId,
result,
});
} catch (error) {
const sandboxError = error as SandboxError;
this.sendToWorker({
type: 'api-response',
id,
extensionId: this.config.extensionId,
error: {
code: sandboxError.code || 'EXECUTION_ERROR',
message: sandboxError.message || 'Unknown error',
},
});
}
}
/**
* Handle API response (for any host-initiated calls).
*/
private handleApiResponse(
id: string,
result?: unknown,
error?: SandboxError,
): void {
const pending = this.pendingRequests.get(id);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(id);
if (error) {
pending.reject(error);
} else {
pending.resolve(result);
}
}
/**
* Send a message to the worker.
*/
private sendToWorker(message: unknown): void {
if (!this.worker) {
logging.error('Cannot send message: worker not initialized');
return;
}
this.worker.postMessage(message);
}
/**
* Dispatch a command to the worker.
*/
dispatchCommand(command: string, args: unknown[] = []): void {
if (!this.isReady) {
logging.warn(`Worker not ready, cannot dispatch command: ${command}`);
return;
}
this.sendToWorker({
type: 'event',
id: nanoid(),
extensionId: this.config.extensionId,
eventName: 'command',
data: { command, args },
});
}
/**
* Emit an event to the worker.
*/
emitEvent(eventName: string, data: unknown): void {
if (!this.isReady) {
logging.warn(`Worker not ready, cannot emit event: ${eventName}`);
return;
}
this.sendToWorker({
type: 'event',
id: nanoid(),
extensionId: this.config.extensionId,
eventName,
data,
});
}
/**
* Set callback for when the worker is ready.
*/
onReady(callback: () => void): void {
this.onReadyCallback = callback;
if (this.isReady) {
callback();
}
}
/**
* Set callback for errors.
*/
onError(callback: (error: SandboxError) => void): void {
this.onErrorCallback = callback;
}
/**
* Check if the worker is ready.
*/
getIsReady(): boolean {
return this.isReady;
}
/**
* Get the sandbox ID.
*/
getSandboxId(): string {
return this.config.sandboxId;
}
/**
* Get the extension ID.
*/
getExtensionId(): string {
return this.config.extensionId;
}
/**
* Dispose of the worker and clean up resources.
*/
dispose(): void {
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
// Reject any pending requests
for (const [, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject({
code: 'DISPOSED',
message: 'Worker sandbox was disposed',
});
}
this.pendingRequests.clear();
this.isReady = false;
logging.info(`WorkerSandbox ${this.config.sandboxId} disposed`);
}
}
/**
* Factory function to create and initialize a WorkerSandbox.
*/
export async function createWorkerSandbox(
config: WorkerSandboxConfig,
): Promise<WorkerSandbox> {
const sandbox = new WorkerSandbox(config);
await sandbox.initialize();
return sandbox;
}
export default WorkerSandbox;
export type { WorkerSandboxConfig };

View File

@@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Extension Sandbox Module
*
* This module provides secure sandboxing capabilities for Superset extensions.
* It supports three tiers of trust:
*
* - **Tier 1 (core)**: Trusted extensions run in the main JavaScript context
* - **Tier 2 (iframe)**: Semi-trusted extensions run in sandboxed iframes
* - **Tier 3 (wasm)**: Untrusted logic runs in WASM sandboxes
*
* @example
* ```typescript
* // Using iframe sandbox for UI extensions
* import { IframeSandbox } from 'src/extensions/sandbox';
*
* <IframeSandbox
* sandboxId="ext-123"
* extensionId="my-extension"
* extensionName="My Extension"
* config={{ trustLevel: 'iframe', permissions: ['sqllab:read'] }}
* />
*
* // Using WASM sandbox for logic-only extensions
* import { createWASMSandbox } from 'src/extensions/sandbox';
*
* const sandbox = await createWASMSandbox({
* sandboxId: 'calc-1',
* extensionId: 'calculator',
* code: 'function calculate(a, b) { return a + b; }',
* config: { trustLevel: 'wasm' },
* });
*
* const result = await sandbox.execute('calculate', [1, 2]);
* ```
*/
// Types
export * from './types';
// Sandbox Bridge (postMessage RPC)
export { SandboxBridge, SandboxBridgeClient } from './SandboxBridge';
// Iframe Sandbox (Tier 2)
export { IframeSandbox } from './IframeSandbox';
// Worker Sandbox (command-only extensions)
export { WorkerSandbox, createWorkerSandbox } from './WorkerSandbox';
export type { WorkerSandboxConfig } from './WorkerSandbox';
// WASM Sandbox (Tier 3)
export { WASMSandbox, createWASMSandbox } from './WASMSandbox';
// Host API Implementation
export { SandboxedExtensionHostImpl } from './SandboxedExtensionHost';
// Sandbox Manager
export { SandboxManager } from './SandboxManager';
export type { SandboxInstance } from './SandboxManager';
// Sandboxed Extension Renderer
export { SandboxedExtensionRenderer } from './SandboxedExtensionRenderer';

View File

@@ -0,0 +1,399 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Type definitions for the Superset extension sandbox system.
*
* This module defines the types and interfaces for the tiered sandbox architecture
* that provides security isolation for extensions. The sandbox system supports three
* trust levels:
*
* - **Tier 1 (core)**: Trusted extensions run in the main context with full access
* - **Tier 2 (iframe)**: Semi-trusted extensions run in sandboxed iframes with controlled API access
* - **Tier 3 (wasm)**: Untrusted logic runs in WASM sandboxes with no DOM access
*/
/**
* Trust levels for extension sandboxing.
*
* @remarks
* The trust level determines how an extension is loaded and what APIs it can access:
*
* - `core`: Full access, runs in main context (requires signature verification)
* - `iframe`: Runs in sandboxed iframe with postMessage API bridge (for UI extensions)
* - `worker`: Runs in Web Worker with postMessage API bridge (for command-only extensions)
* - `wasm`: Runs in WASM sandbox (QuickJS) for logic-only extensions
*/
export type SandboxTrustLevel = 'core' | 'iframe' | 'worker' | 'wasm';
/**
* Configuration for extension sandbox behavior.
*/
export interface SandboxConfig {
/**
* The trust level for this extension.
* Determines how the extension is loaded and sandboxed.
*/
trustLevel: SandboxTrustLevel;
/**
* Permissions requested by the extension.
* Only applies to sandboxed extensions (iframe/wasm).
*/
permissions?: SandboxPermission[];
/**
* Content Security Policy directives for iframe sandboxes.
* Allows fine-grained control over what resources the iframe can load.
*/
csp?: ContentSecurityPolicy;
/**
* Resource limits for WASM sandboxes.
*/
resourceLimits?: WASMResourceLimits;
}
/**
* Permissions that sandboxed extensions can request.
*
* @remarks
* Permissions follow a least-privilege model. Extensions must explicitly
* request each permission they need, and users can review these requests
* before enabling an extension.
*/
export type SandboxPermission =
| 'api:read' // Read-only access to Superset APIs
| 'api:write' // Write access to Superset APIs
| 'sqllab:read' // Read SQL Lab state (queries, results)
| 'sqllab:execute' // Execute SQL queries
| 'dashboard:read' // Read dashboard data
| 'dashboard:write' // Modify dashboards
| 'chart:read' // Read chart data
| 'chart:write' // Modify charts
| 'user:read' // Read current user info
| 'notification:show' // Show notifications to user
| 'modal:open' // Open modal dialogs
| 'navigation:redirect' // Navigate to other pages
| 'clipboard:write' // Write to clipboard
| 'download:file'; // Trigger file downloads
/**
* Content Security Policy configuration for iframe sandboxes.
*/
export interface ContentSecurityPolicy {
/** Allowed sources for default content */
defaultSrc?: string[];
/** Allowed sources for scripts */
scriptSrc?: string[];
/** Allowed sources for styles */
styleSrc?: string[];
/** Allowed sources for images */
imgSrc?: string[];
/** Allowed sources for fonts */
fontSrc?: string[];
/** Allowed sources for fetch/XHR */
connectSrc?: string[];
/** Allowed sources for frames */
frameSrc?: string[];
}
/**
* Resource limits for WASM sandbox execution.
*/
export interface WASMResourceLimits {
/** Maximum memory in bytes (default: 10MB) */
maxMemory?: number;
/** Maximum execution time in milliseconds (default: 5000ms) */
maxExecutionTime?: number;
/** Maximum call stack depth (default: 1000) */
maxStackSize?: number;
}
/**
* Message types for sandbox bridge communication.
*/
export type SandboxMessageType =
| 'api-call' // Extension calling a host API
| 'api-response' // Host responding to an API call
| 'event' // Host pushing an event to extension
| 'event-subscribe' // Extension subscribing to an event
| 'event-unsubscribe' // Extension unsubscribing from an event
| 'ready' // Extension signaling it's ready
| 'error'; // Error message
/**
* Base message structure for sandbox bridge communication.
*/
export interface SandboxMessage {
/** Type of message */
type: SandboxMessageType;
/** Unique correlation ID for request/response matching */
id: string;
/** Extension ID that sent/receives the message */
extensionId: string;
}
/**
* API call message from extension to host.
*/
export interface SandboxApiCallMessage extends SandboxMessage {
type: 'api-call';
/** The API method being called */
method: string;
/** Arguments to pass to the method */
args: unknown[];
}
/**
* API response message from host to extension.
*/
export interface SandboxApiResponseMessage extends SandboxMessage {
type: 'api-response';
/** The result of the API call (if successful) */
result?: unknown;
/** Error details (if failed) */
error?: SandboxError;
}
/**
* Event message from host to extension.
*/
export interface SandboxEventMessage extends SandboxMessage {
type: 'event';
/** The event name */
eventName: string;
/** Event payload data */
data: unknown;
}
/**
* Error structure for sandbox communication.
*/
export interface SandboxError {
/** Error code for programmatic handling */
code: string;
/** Human-readable error message */
message: string;
/** Additional error details */
details?: Record<string, unknown>;
}
/**
* Host API interface exposed to sandboxed extensions.
*
* @remarks
* This interface defines the controlled API surface that sandboxed extensions
* can access. Each method requires specific permissions to be granted.
*/
export interface SandboxedExtensionHostAPI {
// SQL Lab APIs (requires sqllab:read or sqllab:execute)
sqlLab: {
/** Get the current active SQL Lab tab */
getCurrentTab(): Promise<SQLLabTab | null>;
/** Get query results by query ID */
getQueryResults(queryId: string): Promise<QueryResult | null>;
};
// Dashboard APIs (requires dashboard:read)
dashboard: {
/** Get current dashboard context */
getContext(): Promise<DashboardContext | null>;
/** Get dashboard filters */
getFilters(): Promise<DashboardFilter[]>;
};
// Chart APIs (requires chart:read)
chart: {
/** Get chart data by chart ID */
getData(chartId: number): Promise<ChartData | null>;
};
// User APIs (requires user:read)
user: {
/** Get current user info */
getCurrentUser(): Promise<UserInfo>;
};
// UI APIs
ui: {
/** Show a notification (requires notification:show) */
showNotification(
message: string,
type: 'info' | 'success' | 'warning' | 'error',
): void;
/** Open a modal dialog (requires modal:open) */
openModal(config: ModalConfig): Promise<ModalResult>;
/** Navigate to a URL (requires navigation:redirect) */
navigateTo(path: string): void;
};
// Utility APIs
utils: {
/** Copy text to clipboard (requires clipboard:write) */
copyToClipboard(text: string): Promise<boolean>;
/** Trigger a file download (requires download:file) */
downloadFile(data: Blob, filename: string): void;
/** Get CSRF token for API calls */
getCSRFToken(): Promise<string>;
};
}
// Supporting types for the host API
export interface SQLLabTab {
id: string;
title: string;
databaseId: number | null;
catalog: string | null;
schema: string | null;
sql: string;
}
export interface QueryResult {
queryId: string;
status: 'pending' | 'running' | 'success' | 'error';
data: Record<string, unknown>[] | null;
columns: string[];
error: string | null;
}
export interface DashboardContext {
id: number;
title: string;
slug: string | null;
}
export interface DashboardFilter {
id: string;
column: string;
value: unknown;
}
export interface ChartData {
chartId: number;
data: Record<string, unknown>[];
columns: string[];
}
export interface UserInfo {
id: number;
username: string;
firstName: string;
lastName: string;
email: string;
roles: string[];
}
export interface ModalConfig {
title: string;
content: string;
okText?: string;
cancelText?: string;
type?: 'info' | 'confirm' | 'warning' | 'error';
}
export interface ModalResult {
confirmed: boolean;
}
/**
* Configuration for iframe sandbox initialization.
*/
export interface IframeSandboxConfig {
/** Unique ID for this sandbox instance */
sandboxId: string;
/** Extension ID being sandboxed */
extensionId: string;
/** HTML content to load in the iframe (srcdoc) */
content: string;
/** Sandbox configuration */
config: SandboxConfig;
/** Callback when sandbox is ready */
onReady?: () => void;
/** Callback when sandbox encounters an error */
onError?: (error: SandboxError) => void;
}
/**
* Configuration for WASM sandbox initialization.
*/
export interface WASMSandboxConfig {
/** Unique ID for this sandbox instance */
sandboxId: string;
/** Extension ID being sandboxed */
extensionId: string;
/** JavaScript code to execute */
code: string;
/** Sandbox configuration */
config: SandboxConfig;
/** APIs to inject into the sandbox */
injectedAPIs?: Record<string, unknown>;
}
/**
* Result of WASM sandbox execution.
*/
export interface WASMExecutionResult {
/** Whether execution was successful */
success: boolean;
/** Return value from the executed code */
result?: unknown;
/** Error if execution failed */
error?: SandboxError;
/** Execution time in milliseconds */
executionTime: number;
/** Memory used in bytes */
memoryUsed: number;
}
/**
* Extension manifest sandbox configuration.
*
* @remarks
* This is added to extension.json to declare sandbox requirements.
*/
export interface ExtensionSandboxManifest {
/**
* Trust level required for this extension.
* @default 'iframe'
*/
trustLevel?: SandboxTrustLevel;
/**
* Permissions requested by the extension.
*/
permissions?: SandboxPermission[];
/**
* Custom CSP directives (for iframe sandboxes).
*/
csp?: ContentSecurityPolicy;
/**
* Resource limits (for WASM sandboxes).
*/
resourceLimits?: WASMResourceLimits;
/**
* Whether this extension requires signature verification.
* Required for 'core' trust level.
*/
requiresSignature?: boolean;
}

View File

@@ -74,6 +74,8 @@ const TYPESCRIPT_MEMORY_LIMIT = 4096;
const output = {
path: BUILD_DIR,
publicPath: '/static/assets/',
// Output path for WebAssembly modules (used by QuickJS sandbox)
webassemblyModuleFilename: 'wasm/[hash].wasm',
};
if (isDevMode) {
output.filename = '[name].[contenthash:8].entry.js';
@@ -308,6 +310,10 @@ const config = {
config: [__filename],
},
},
// Enable WebAssembly support for QuickJS sandbox
experiments: {
asyncWebAssembly: true,
},
output,
stats: 'minimal',
/*

View File

@@ -2415,6 +2415,24 @@ except ImportError:
LOCAL_EXTENSIONS: list[str] = []
EXTENSIONS_PATH: str | None = None
# Extension Trust Configuration
# Controls which extensions can run at which trust level
EXTENSIONS_TRUST_CONFIG: dict[str, Any] = {
# List of extension IDs allowed to run as 'core' (full main-context access)
"trusted_extensions": [],
# Allow any extension to run as 'core' without signature verification
# WARNING: Only enable for development, never in production
"allow_unsigned_core": False,
# Default trust level for extensions without explicit sandbox config
# Options: 'core', 'iframe', 'worker', 'wasm'
"default_trust_level": "iframe",
# Require valid signatures for extensions requesting 'core' trust
# Recommended for production deployments
"require_core_signatures": False,
# Public keys for signature verification (file paths or PEM strings)
"trusted_signers": [],
}
# -------------------------------------------------------------------
# * WARNING: STOP EDITING HERE *
# -------------------------------------------------------------------

View File

@@ -0,0 +1,416 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Extension security and trust validation.
This module provides trust-level validation and signature verification
for Superset extensions. It enforces the configured trust policy to ensure
that only approved extensions can run with elevated privileges.
Trust Levels:
- core: Full access, runs in main context (requires explicit trust or signature)
- iframe: Runs in sandboxed iframe with controlled API access
- worker: Runs in Web Worker with postMessage API
- wasm: Runs in WASM sandbox with no DOM access
Example configuration in superset_config.py:
EXTENSIONS_TRUST_CONFIG = {
"trusted_extensions": ["official-plugin", "enterprise-sso"],
"allow_unsigned_core": False,
"default_trust_level": "iframe",
"require_core_signatures": True,
"trusted_signers": ["/etc/superset/keys/publisher.pub"],
}
"""
from __future__ import annotations
import base64
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TYPE_CHECKING
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives.serialization import load_pem_public_key
if TYPE_CHECKING:
from flask import Flask
logger = logging.getLogger(__name__)
# Valid trust levels
TRUST_LEVELS = frozenset({"core", "iframe", "worker", "wasm"})
# Default trust configuration
DEFAULT_TRUST_CONFIG: dict[str, Any] = {
"trusted_extensions": [],
"allow_unsigned_core": False,
"default_trust_level": "iframe",
"require_core_signatures": False,
"trusted_signers": [],
}
@dataclass
class TrustDecision:
"""Result of trust level validation for an extension.
Attributes:
extension_id: The unique identifier of the extension.
requested_trust_level: The trust level requested in the extension manifest.
granted_trust_level: The actual trust level granted after validation.
signature_valid: Whether signature verification passed (None if not checked).
rejection_reason: Human-readable reason if trust was downgraded.
"""
extension_id: str
requested_trust_level: str
granted_trust_level: str
signature_valid: bool | None = None
rejection_reason: str | None = None
@property
def was_downgraded(self) -> bool:
"""Check if the extension was downgraded from its requested trust level."""
return self.requested_trust_level != self.granted_trust_level
class ExtensionSecurityManager:
"""Manages extension trust validation and signature verification.
This class is responsible for validating extension trust levels based on
the configured trust policy. It ensures that:
1. Only trusted extensions can run as 'core' trust level
2. Signatures are verified for extensions that require them
3. Untrusted extensions are downgraded to a safer trust level
Example:
>>> config = {"trusted_extensions": ["my-ext"], "default_trust_level": "iframe"}
>>> manager = ExtensionSecurityManager(config)
>>> decision = manager.validate_trust_level("my-ext", "core")
>>> decision.granted_trust_level
'core'
>>> decision = manager.validate_trust_level("unknown-ext", "core")
>>> decision.granted_trust_level
'iframe'
"""
def __init__(self, config: dict[str, Any] | None = None) -> None:
"""Initialize the security manager with configuration.
Args:
config: Trust configuration dictionary. Uses DEFAULT_TRUST_CONFIG
for any missing keys.
"""
config = {**DEFAULT_TRUST_CONFIG, **(config or {})}
self.trusted_extensions: frozenset[str] = frozenset(
config.get("trusted_extensions", [])
)
self.allow_unsigned_core: bool = config.get("allow_unsigned_core", False)
self.default_trust_level: str = config.get("default_trust_level", "iframe")
self.require_core_signatures: bool = config.get(
"require_core_signatures", False
)
self._trusted_signers: list[ed25519.Ed25519PublicKey] = []
# Load trusted signers
signers = config.get("trusted_signers", [])
self._load_trusted_signers(signers)
# Validate default trust level
if self.default_trust_level not in TRUST_LEVELS:
logger.warning(
"Invalid default_trust_level '%s', using 'iframe'",
self.default_trust_level,
)
self.default_trust_level = "iframe"
def _load_trusted_signers(self, signers: list[str]) -> None:
"""Load public keys from PEM strings or file paths.
Args:
signers: List of PEM strings or file paths to public key files.
"""
for signer in signers:
try:
# Check if it's a file path
if not signer.startswith("-----BEGIN"):
path = Path(signer)
if path.exists():
pem_data = path.read_bytes()
else:
logger.warning("Trusted signer key file not found: %s", signer)
continue
else:
pem_data = signer.encode("utf-8")
public_key = load_pem_public_key(pem_data)
if isinstance(public_key, ed25519.Ed25519PublicKey):
self._trusted_signers.append(public_key)
else:
logger.warning(
"Trusted signer key is not Ed25519: %s",
signer[:50] + "..." if len(signer) > 50 else signer,
)
except Exception:
logger.exception("Failed to load trusted signer key")
if signers and not self._trusted_signers:
logger.warning(
"No valid trusted signers loaded from %d configured keys",
len(signers),
)
def validate_trust_level(
self,
extension_id: str,
manifest_trust_level: str | None,
signature: bytes | None = None,
manifest_bytes: bytes | None = None,
) -> TrustDecision:
"""Validate and potentially downgrade extension trust level.
This method checks whether an extension should be granted its
requested trust level based on the configured trust policy.
Args:
extension_id: The unique identifier of the extension.
manifest_trust_level: The trust level declared in the extension manifest.
signature: Optional base64-encoded signature of the manifest.
manifest_bytes: Optional raw manifest content for signature verification.
Returns:
TrustDecision containing the granted trust level and any rejection reason.
"""
requested = manifest_trust_level or self.default_trust_level
# Validate requested trust level
if requested not in TRUST_LEVELS:
logger.warning(
"Extension %s requested invalid trust level '%s', using default",
extension_id,
requested,
)
requested = self.default_trust_level
# Core trust requires additional validation
if requested == "core":
return self._validate_core_trust(extension_id, signature, manifest_bytes)
# Non-core trust levels pass through without signature check
return TrustDecision(
extension_id=extension_id,
requested_trust_level=requested,
granted_trust_level=requested,
signature_valid=None,
)
def _validate_core_trust(
self,
extension_id: str,
signature: bytes | None,
manifest_bytes: bytes | None,
) -> TrustDecision:
"""Validate an extension requesting core trust.
Core trust validation follows this order:
1. Check if extension is in trusted_extensions whitelist
2. Check if allow_unsigned_core is enabled (dev mode)
3. Check signature if require_core_signatures is enabled
4. Downgrade to default_trust_level if none of the above pass
Args:
extension_id: The unique identifier of the extension.
signature: Optional base64-encoded signature.
manifest_bytes: Optional manifest content for verification.
Returns:
TrustDecision for core trust request.
"""
# Check if extension is explicitly trusted
if extension_id in self.trusted_extensions:
sig_valid = None
if signature and manifest_bytes:
sig_valid = self._verify_signature(signature, manifest_bytes)
if sig_valid:
logger.info(
"Extension %s granted core trust (trusted + valid signature)",
extension_id,
)
else:
logger.info(
"Extension %s granted core trust (trusted, invalid signature)",
extension_id,
)
else:
logger.info(
"Extension %s granted core trust (in trusted list)",
extension_id,
)
return TrustDecision(
extension_id=extension_id,
requested_trust_level="core",
granted_trust_level="core",
signature_valid=sig_valid,
)
# Check if unsigned core is allowed (dev mode)
if self.allow_unsigned_core:
logger.warning(
"Extension %s granted core trust (allow_unsigned_core=True). "
"This should only be used in development!",
extension_id,
)
return TrustDecision(
extension_id=extension_id,
requested_trust_level="core",
granted_trust_level="core",
signature_valid=None,
)
# Check signature if core signatures are required
if self.require_core_signatures:
if not signature or not manifest_bytes:
logger.warning(
"Extension %s denied core trust: signature required but missing",
extension_id,
)
return TrustDecision(
extension_id=extension_id,
requested_trust_level="core",
granted_trust_level=self.default_trust_level,
signature_valid=False,
rejection_reason="Core trust requires a valid signature",
)
if self._verify_signature(signature, manifest_bytes):
logger.info(
"Extension %s granted core trust (valid signature)",
extension_id,
)
return TrustDecision(
extension_id=extension_id,
requested_trust_level="core",
granted_trust_level="core",
signature_valid=True,
)
else:
logger.warning(
"Extension %s denied core trust: invalid signature",
extension_id,
)
return TrustDecision(
extension_id=extension_id,
requested_trust_level="core",
granted_trust_level=self.default_trust_level,
signature_valid=False,
rejection_reason="Signature verification failed",
)
# Downgrade to default trust level
logger.info(
"Extension %s downgraded from core to %s (not in trusted list)",
extension_id,
self.default_trust_level,
)
return TrustDecision(
extension_id=extension_id,
requested_trust_level="core",
granted_trust_level=self.default_trust_level,
signature_valid=None,
rejection_reason="Extension not in trusted list",
)
def _verify_signature(self, signature: bytes, data: bytes) -> bool:
"""Verify signature against trusted signers.
Args:
signature: Base64-encoded Ed25519 signature.
data: The data that was signed (manifest content).
Returns:
True if any trusted signer's public key verifies the signature.
"""
if not self._trusted_signers:
logger.debug("No trusted signers configured, signature cannot be verified")
return False
try:
sig_bytes = base64.b64decode(signature)
except Exception:
logger.warning("Failed to decode signature as base64")
return False
for public_key in self._trusted_signers:
try:
public_key.verify(sig_bytes, data)
return True
except InvalidSignature:
continue
except Exception:
logger.exception("Error during signature verification")
continue
return False
# Module-level instance cache
_security_manager: ExtensionSecurityManager | None = None
def get_extension_security_manager(
app: Flask | None = None,
) -> ExtensionSecurityManager:
"""Get the extension security manager instance.
This function returns a cached singleton instance of the security manager,
initialized with the application's EXTENSIONS_TRUST_CONFIG.
Args:
app: Optional Flask application. If not provided, uses current_app.
Returns:
The ExtensionSecurityManager instance.
"""
global _security_manager
if _security_manager is not None:
return _security_manager
if app is None:
from flask import current_app
app = current_app
config = app.config.get("EXTENSIONS_TRUST_CONFIG", DEFAULT_TRUST_CONFIG)
_security_manager = ExtensionSecurityManager(config)
return _security_manager
def reset_security_manager() -> None:
"""Reset the cached security manager instance.
This is primarily useful for testing to ensure a fresh manager is created.
"""
global _security_manager
_security_manager = None

View File

@@ -15,10 +15,16 @@
# specific language governing permissions and limitations
# under the License.
from dataclasses import dataclass
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from superset_core.extensions.types import Manifest
if TYPE_CHECKING:
from superset.extensions.security import TrustDecision
@dataclass
class BundleFile:
@@ -34,6 +40,7 @@ class LoadedExtension:
frontend: dict[str, bytes]
backend: dict[str, bytes]
version: str
source_base_path: (
str # Base path for traceback filenames (absolute path or supx:// URL)
)
source_base_path: str # Base path for traceback filenames (absolute or supx://)
signature: bytes | None = None # Base64-encoded Ed25519 signature
manifest_bytes: bytes | None = None # Raw manifest.json for signature verification
trust_decision: TrustDecision | None = field(default=None, repr=False)

View File

@@ -148,7 +148,9 @@ def get_bundle_files_from_path(base_path: str) -> Generator[BundleFile, None, No
def get_loaded_extension(
files: Iterable[BundleFile], source_base_path: str
files: Iterable[BundleFile],
source_base_path: str,
signature: bytes | None = None,
) -> LoadedExtension:
"""
Load an extension from bundle files.
@@ -158,9 +160,11 @@ def get_loaded_extension(
this should be an absolute filesystem path to the dist directory.
For EXTENSIONS_PATH (.supx files), this should be a supx:// URL
(e.g., "supx://extension-id").
:param signature: Optional base64-encoded signature of manifest.json
:returns: LoadedExtension instance
"""
manifest: Manifest | None = None
manifest_bytes: bytes | None = None
frontend: dict[str, bytes] = {}
backend: dict[str, bytes] = {}
@@ -169,6 +173,7 @@ def get_loaded_extension(
content = file.content
if filename == "manifest.json":
manifest_bytes = content
try:
manifest_data = json.loads(content)
manifest = Manifest.model_validate(manifest_data)
@@ -177,6 +182,10 @@ def get_loaded_extension(
except Exception as e:
raise Exception(f"Failed to parse manifest.json: {e}") from e
elif filename == "manifest.sig":
# Signature file - store for later use
signature = content
elif (match := FRONTEND_REGEX.match(filename)) is not None:
frontend[match.group(1)] = content
@@ -197,6 +206,8 @@ def get_loaded_extension(
backend=backend,
version=manifest.version,
source_base_path=source_base_path,
signature=signature,
manifest_bytes=manifest_bytes,
)
@@ -209,20 +220,96 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
"description": manifest.description or "",
"dependencies": manifest.dependencies,
}
# Include sandbox configuration if present
if manifest.sandbox:
extension_data["sandbox"] = manifest.sandbox.model_dump()
# Include trust decision from backend validation
if extension.trust_decision:
extension_data["trustLevel"] = extension.trust_decision.granted_trust_level
extension_data["signatureValid"] = extension.trust_decision.signature_valid
if extension.trust_decision.was_downgraded:
extension_data["trustDowngraded"] = True
extension_data["trustDowngradeReason"] = (
extension.trust_decision.rejection_reason
)
elif manifest.sandbox:
# Fallback to manifest trust level if no decision (shouldn't happen)
extension_data["trustLevel"] = manifest.sandbox.trustLevel
extension_data["signatureValid"] = None
if manifest.frontend:
frontend = manifest.frontend
module_federation = frontend.moduleFederation
remote_entry_url = f"/api/v1/extensions/{manifest.id}/{frontend.remoteEntry}"
extension_data.update(
{
"remoteEntry": remote_entry_url,
"exposedModules": module_federation.exposes,
"contributions": frontend.contributions.model_dump(),
}
)
# Build frontend data based on sandbox type
frontend_data: dict[str, Any] = {
"contributions": frontend.contributions.model_dump(),
}
# Include remoteEntry for iframe/core sandboxes
if frontend.remoteEntry:
remote_entry_url = (
f"/api/v1/extensions/{manifest.id}/{frontend.remoteEntry}"
)
frontend_data["remoteEntry"] = remote_entry_url
# Include Module Federation config if available
if frontend.moduleFederation:
frontend_data["exposedModules"] = frontend.moduleFederation.exposes
# Include workerEntry for worker sandboxes
if frontend.workerEntry:
worker_entry_url = (
f"/api/v1/extensions/{manifest.id}/{frontend.workerEntry}"
)
frontend_data["workerEntry"] = worker_entry_url
extension_data.update(frontend_data)
return extension_data
def _apply_trust_validation(extension: LoadedExtension) -> LoadedExtension:
"""Apply trust validation to a loaded extension.
This validates the extension's requested trust level against the
configured trust policy and updates the extension with the decision.
"""
from superset.extensions.security import get_extension_security_manager
security_manager = get_extension_security_manager()
# Get the requested trust level from manifest
manifest_trust_level = None
if extension.manifest.sandbox:
manifest_trust_level = extension.manifest.sandbox.trustLevel
# Validate trust level
trust_decision = security_manager.validate_trust_level(
extension_id=extension.id,
manifest_trust_level=manifest_trust_level,
signature=extension.signature,
manifest_bytes=extension.manifest_bytes,
)
# Update extension with trust decision
extension.trust_decision = trust_decision
# If trust was downgraded, update the manifest sandbox config
if trust_decision.was_downgraded and extension.manifest.sandbox:
logger.warning(
"Extension %s trust downgraded from %s to %s: %s",
extension.id,
trust_decision.requested_trust_level,
trust_decision.granted_trust_level,
trust_decision.rejection_reason,
)
extension.manifest.sandbox.trustLevel = trust_decision.granted_trust_level
return extension
def get_extensions() -> dict[str, LoadedExtension]:
extensions: dict[str, LoadedExtension] = {}
@@ -232,12 +319,16 @@ def get_extensions() -> dict[str, LoadedExtension]:
# Use absolute filesystem path to dist directory for tracebacks
abs_dist_path = str((Path(path) / "dist").resolve())
extension = get_loaded_extension(files, source_base_path=abs_dist_path)
extension = _apply_trust_validation(extension)
extension_id = extension.manifest.id
extensions[extension_id] = extension
logger.info(
"Loading extension %s (ID: %s) from local filesystem",
"Loading extension %s (ID: %s) from local filesystem with trust level %s",
extension.name,
extension_id,
extension.trust_decision.granted_trust_level
if extension.trust_decision
else "unknown",
)
# Load extensions from discovery path (.supx files)
@@ -245,13 +336,18 @@ def get_extensions() -> dict[str, LoadedExtension]:
from superset.extensions.discovery import discover_and_load_extensions
for extension in discover_and_load_extensions(extensions_path):
extension = _apply_trust_validation(extension)
extension_id = extension.manifest.id
if extension_id not in extensions: # Don't override LOCAL_EXTENSIONS
extensions[extension_id] = extension
logger.info(
"Loading extension %s (ID: %s) from discovery path",
"Loading extension %s (ID: %s) from discovery path "
"with trust level %s",
extension.name,
extension_id,
extension.trust_decision.granted_trust_level
if extension.trust_decision
else "unknown",
)
else:
logger.info(

View File

@@ -0,0 +1,323 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for the extension security module."""
from __future__ import annotations
from superset_core.extensions.signing import generate_keypair, sign_manifest
from superset.extensions.security import (
ExtensionSecurityManager,
reset_security_manager,
TrustDecision,
)
class TestTrustDecision:
"""Tests for TrustDecision dataclass."""
def test_was_downgraded_true(self) -> None:
decision = TrustDecision(
extension_id="test",
requested_trust_level="core",
granted_trust_level="iframe",
)
assert decision.was_downgraded is True
def test_was_downgraded_false(self) -> None:
decision = TrustDecision(
extension_id="test",
requested_trust_level="iframe",
granted_trust_level="iframe",
)
assert decision.was_downgraded is False
class TestExtensionSecurityManager:
"""Tests for ExtensionSecurityManager class."""
def setup_method(self) -> None:
"""Reset security manager before each test."""
reset_security_manager()
def test_default_config(self) -> None:
"""Test that default configuration is applied."""
manager = ExtensionSecurityManager()
assert manager.trusted_extensions == frozenset()
assert manager.allow_unsigned_core is False
assert manager.default_trust_level == "iframe"
assert manager.require_core_signatures is False
def test_custom_config(self) -> None:
"""Test that custom configuration is applied."""
config = {
"trusted_extensions": ["ext-1", "ext-2"],
"allow_unsigned_core": True,
"default_trust_level": "wasm",
"require_core_signatures": True,
}
manager = ExtensionSecurityManager(config)
assert manager.trusted_extensions == frozenset(["ext-1", "ext-2"])
assert manager.allow_unsigned_core is True
assert manager.default_trust_level == "wasm"
assert manager.require_core_signatures is True
def test_invalid_default_trust_level_falls_back(self) -> None:
"""Test that invalid default trust level falls back to iframe."""
config = {"default_trust_level": "invalid"}
manager = ExtensionSecurityManager(config)
assert manager.default_trust_level == "iframe"
class TestTrustLevelValidation:
"""Tests for trust level validation logic."""
def setup_method(self) -> None:
"""Reset security manager before each test."""
reset_security_manager()
def test_trusted_extension_gets_core(self) -> None:
"""Test that trusted extensions get core trust."""
config = {"trusted_extensions": ["my-ext"]}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level("my-ext", "core")
assert decision.granted_trust_level == "core"
assert decision.was_downgraded is False
assert decision.rejection_reason is None
def test_untrusted_extension_downgraded(self) -> None:
"""Test that untrusted extensions requesting core are downgraded."""
config = {"trusted_extensions": [], "default_trust_level": "iframe"}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level("unknown-ext", "core")
assert decision.granted_trust_level == "iframe"
assert decision.was_downgraded is True
assert decision.rejection_reason is not None
def test_allow_unsigned_core_grants_core(self) -> None:
"""Test that allow_unsigned_core allows any extension to run as core."""
config = {"allow_unsigned_core": True}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level("any-ext", "core")
assert decision.granted_trust_level == "core"
assert decision.was_downgraded is False
def test_non_core_trust_passes_through(self) -> None:
"""Test that non-core trust levels pass through unchanged."""
manager = ExtensionSecurityManager()
for trust_level in ["iframe", "worker", "wasm"]:
decision = manager.validate_trust_level("ext", trust_level)
assert decision.granted_trust_level == trust_level
assert decision.was_downgraded is False
def test_invalid_trust_level_uses_default(self) -> None:
"""Test that invalid trust level falls back to default."""
config = {"default_trust_level": "wasm"}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level("ext", "invalid")
assert decision.granted_trust_level == "wasm"
def test_none_trust_level_uses_default(self) -> None:
"""Test that None trust level uses default."""
config = {"default_trust_level": "worker"}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level("ext", None)
assert decision.granted_trust_level == "worker"
class TestSignatureVerification:
"""Tests for signature verification functionality."""
def setup_method(self) -> None:
"""Reset security manager before each test."""
reset_security_manager()
def test_require_signatures_rejects_unsigned(self) -> None:
"""Test that require_core_signatures rejects unsigned extensions."""
config = {"require_core_signatures": True}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level(
extension_id="unsigned-ext",
manifest_trust_level="core",
signature=None,
manifest_bytes=None,
)
assert decision.granted_trust_level == "iframe"
assert decision.signature_valid is False
assert decision.rejection_reason is not None
assert "signature" in decision.rejection_reason.lower()
def test_valid_signature_grants_core(self) -> None:
"""Test that valid signature grants core trust."""
# Generate test keypair
private_pem, public_pem = generate_keypair()
manifest_bytes = b'{"id": "test", "name": "Test Extension"}'
signature = sign_manifest(manifest_bytes, private_pem)
config = {
"require_core_signatures": True,
"trusted_signers": [public_pem.decode("utf-8")],
}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level(
extension_id="signed-ext",
manifest_trust_level="core",
signature=signature,
manifest_bytes=manifest_bytes,
)
assert decision.granted_trust_level == "core"
assert decision.signature_valid is True
def test_invalid_signature_downgrades(self) -> None:
"""Test that invalid signature downgrades trust."""
# Generate two different keypairs
private_pem1, _ = generate_keypair()
_, public_pem2 = generate_keypair()
manifest_bytes = b'{"id": "test", "name": "Test Extension"}'
# Sign with key 1 but verify with key 2
signature = sign_manifest(manifest_bytes, private_pem1)
config = {
"require_core_signatures": True,
"trusted_signers": [public_pem2.decode("utf-8")],
}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level(
extension_id="bad-sig-ext",
manifest_trust_level="core",
signature=signature,
manifest_bytes=manifest_bytes,
)
assert decision.granted_trust_level == "iframe"
assert decision.signature_valid is False
def test_trusted_extension_with_signature(self) -> None:
"""Test that trusted extensions can also have signature verification."""
private_pem, public_pem = generate_keypair()
manifest_bytes = b'{"id": "trusted-ext", "name": "Trusted"}'
signature = sign_manifest(manifest_bytes, private_pem)
config = {
"trusted_extensions": ["trusted-ext"],
"trusted_signers": [public_pem.decode("utf-8")],
}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level(
extension_id="trusted-ext",
manifest_trust_level="core",
signature=signature,
manifest_bytes=manifest_bytes,
)
assert decision.granted_trust_level == "core"
assert decision.signature_valid is True
def test_tampered_manifest_fails_verification(self) -> None:
"""Test that tampered manifest fails signature verification."""
private_pem, public_pem = generate_keypair()
original_manifest = b'{"id": "test", "name": "Test Extension"}'
tampered_manifest = b'{"id": "test", "name": "Malicious Extension"}'
signature = sign_manifest(original_manifest, private_pem)
config = {
"require_core_signatures": True,
"trusted_signers": [public_pem.decode("utf-8")],
}
manager = ExtensionSecurityManager(config)
decision = manager.validate_trust_level(
extension_id="tampered-ext",
manifest_trust_level="core",
signature=signature,
manifest_bytes=tampered_manifest,
)
assert decision.granted_trust_level == "iframe"
assert decision.signature_valid is False
class TestSigningUtilities:
"""Tests for the signing utilities module."""
def test_generate_keypair(self) -> None:
"""Test that generate_keypair creates valid keys."""
private_pem, public_pem = generate_keypair()
assert b"PRIVATE KEY" in private_pem
assert b"PUBLIC KEY" in public_pem
def test_sign_and_verify(self) -> None:
"""Test that signing and verification work correctly."""
from superset_core.extensions.signing import verify_signature
private_pem, public_pem = generate_keypair()
manifest = b'{"id": "test", "name": "Test"}'
signature = sign_manifest(manifest, private_pem)
assert verify_signature(manifest, signature, public_pem) is True
def test_verify_fails_for_wrong_key(self) -> None:
"""Test that verification fails with wrong key."""
from superset_core.extensions.signing import verify_signature
private_pem1, _ = generate_keypair()
_, public_pem2 = generate_keypair()
manifest = b'{"id": "test", "name": "Test"}'
signature = sign_manifest(manifest, private_pem1)
assert verify_signature(manifest, signature, public_pem2) is False
def test_verify_fails_for_tampered_data(self) -> None:
"""Test that verification fails for tampered data."""
from superset_core.extensions.signing import verify_signature
private_pem, public_pem = generate_keypair()
manifest = b'{"id": "test", "name": "Test"}'
tampered = b'{"id": "test", "name": "Evil"}'
signature = sign_manifest(manifest, private_pem)
assert verify_signature(tampered, signature, public_pem) is False
def test_get_public_key_fingerprint(self) -> None:
"""Test that fingerprint generation works."""
from superset_core.extensions.signing import get_public_key_fingerprint
_, public_pem = generate_keypair()
fingerprint = get_public_key_fingerprint(public_pem)
assert len(fingerprint) == 16
assert isinstance(fingerprint, str)