diff --git a/csrf_safe-using-origin/README.md b/csrf_safe-using-origin/README.md new file mode 100644 index 00000000..7adb99b9 --- /dev/null +++ b/csrf_safe-using-origin/README.md @@ -0,0 +1,168 @@ +# Secure Databricks App (CSRF via Origin Header) + +This application demonstrates best practices for building secure Databricks apps using **Origin header validation for CSRF protection** instead of CSRF tokens. This approach provides an alternative CSRF protection method while maintaining comprehensive security features. + +## Security Features + +### 1. CSRF Protection via Origin Header Validation +**Origin Header Validation** provides CSRF protection by validating the `Origin` HTTP header on every request. This method offers an alternative to token-based CSRF protection. + +**How it works:** +- All state-changing requests (POST, PUT, DELETE, PATCH) are validated via the `@app.before_request` hook +- The app checks if the `Origin` header matches the expected app URL (`DATABRICKS_APP_URL`) +- Requests without an `Origin` header or with `Origin: null` are allowed (same-origin requests) + - **Important:** Browsers typically don't send `Origin` headers with GET requests, so these are allowed by default + - **Do NOT use GET requests for state-changing operations** (create, update, delete) - always use POST, PUT, DELETE, or PATCH for state changes +- When `Origin` is present, it must: + - Use HTTPS protocol + - Match the configured app URL (case-insensitive comparison) + - Contain only valid characters + +**Validation Logic:** +```python +# If Origin header is absent or null - ALLOW (same-origin) +# If Origin header is present: +# - Must start with https:// +# - Must match APP_URL +# - Must contain only valid characters +``` + +**Reference:** [OWASP CSRF Prevention Cheat Sheet - Verifying Origin](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#verifying-origin-with-standard-headers) + +### 2. User Authorization (OBO - On-Behalf-Of-User) +**On-Behalf-Of-User (OBO) Authorization** enables the application to execute SQL queries using the authenticated user's access token. The user's identity and permissions are maintained throughout the query execution, ensuring proper access control based on Unity Catalog policies. + +**How it works:** +- User's access token is retrieved from the `x-forwarded-access-token` header (provided by Databricks) +- SQL queries execute with the user's identity and permissions +- Unity Catalog enforces row-level filters, column masks, and other access controls +- All operations are audited under the user's identity + +Reference: [Databricks Apps Authorization Documentation](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth) + +### 3. CSP (Content Security Policy) +The application enforces the following Content Security Policy to prevent XSS and injection attacks: + +``` +Content-Security-Policy: default-src https:; script-src https:; style-src 'self' 'unsafe-inline'; img-src https: data:; font-src https: data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; +``` + +**Policy Details:** +- `default-src https:` - Only allow HTTPS resources by default +- `script-src https:` - Only allow scripts from HTTPS sources +- `style-src 'self' 'unsafe-inline'` - Allow styles from same origin and inline styles +- `img-src https: data:` - Allow images from HTTPS and data URIs +- `font-src https: data:` - Allow fonts from HTTPS and data URIs +- `object-src 'none'` - Block all plugins (Flash, Java, etc.) +- `base-uri 'self'` - Restrict base tag to same origin +- `frame-ancestors 'none'` - Prevent the page from being embedded in iframes (clickjacking protection) + +### 4. CORS (Cross-Origin Resource Sharing) +CORS headers are **disabled by default** for security. To enable CORS support, add the following environment variable in your `app.yaml`: + +```yaml +env: + - name: "CORS_ENABLE" + value: "true" +``` + +**Example `app.yaml` with CORS enabled:** +```yaml +display_name: "csrf-using-origin" +env: + - name: "SERVER_PORT" + value: "8000" + - name: "DATABRICKS_WAREHOUSE_ID" + valueFrom: "sql-warehouse" + - name: "CORS_ENABLE" + value: "true" +``` + +When enabled, the following CORS headers will be set: +- `Access-Control-Allow-Origin`: Configured app URL +- `Access-Control-Allow-Credentials`: false +- `Access-Control-Allow-Methods`: GET, POST, PUT, DELETE, PATCH +- `Access-Control-Allow-Headers`: Content-Type, X-Requested-With + +## Additional Security Features + +- **SQL Injection Protection**: Uses parameterized queries with placeholder binding (`?`) to prevent SQL injection attacks. User input is passed as query parameters, never directly concatenated into SQL statements. The application queries a fixed table (`samples.nyctaxi.trips`) and only accepts user input for the WHERE clause value (pickup_zip), which is safely parameterized. + ```python + # Safe parameterized query + query = "SELECT * FROM samples.nyctaxi.trips WHERE pickup_zip = ? LIMIT 10" + cursor.execute(query, [str(pickup_zip_value)]) + ``` + +- **Input Sanitization**: All user inputs and query results are escaped using MarkupSafe to prevent XSS attacks + +- **X-Content-Type-Options**: Set to `nosniff` to prevent MIME-sniffing attacks + +- **Secure Token Handling**: User access tokens are handled securely via headers + +- **Unity Catalog Integration**: Query execution respects all Unity Catalog permissions, row filters, and column masks + +## Environment Variables + +### Automatically Injected by Databricks Apps + +These variables are automatically set by Databricks when you deploy your app: + +| Variable | Description | Auto-Injected | +|----------|-------------|---------------| +| `DATABRICKS_HOST` | Your Databricks workspace hostname | ✅ Yes | +| `DATABRICKS_CLIENT_ID` | App service principal OAuth client ID | ✅ Yes | +| `DATABRICKS_CLIENT_SECRET` | App service principal OAuth client secret | ✅ Yes | +| `DATABRICKS_APP_NAME` | Name of your Databricks app | ✅ Yes | +| `DATABRICKS_APP_URL` | URL where your app is accessible | ✅ Yes | + +### Required Configuration + +These variables must be configured in your `app.yaml`: + +| Variable | Description | Required | +|----------|-------------|----------| +| `DATABRICKS_WAREHOUSE_ID` | SQL warehouse ID | Yes | +| `SERVER_PORT` | Port to run the application (default: 8000) | No | +| `CORS_ENABLE` | Enable CORS headers (default: false) | No | + +## Configuration + +1. If you want to add more libraries add them in requirements.txt + +2. Configure your `app.yaml` with required environment variables + +3. **Enable User Authorization**: Ensure your Databricks app has user authorization scopes configured to access SQL warehouses on behalf of users. This is configured in the Databricks UI when creating or editing the app. + +## File Structure + +``` +csrf-origin-app/ +├── app.py +├── app.yaml +├── requirements.txt +├── README.md +├── static/ +│ ├── css/ +│ │ └── style.css +│ └── js/ +│ └── app.js +└── templates/ + └── index.html +``` + +### File Descriptions + +- **app.py** - Main Flask application file containing route handlers, Origin header validation for CSRF protection, security header configurations, and SQL query execution logic with parameterized queries (SQL injection protection) using user token (OBO) authentication. + +- **app.yaml** - Databricks app configuration file that defines environment variables and deployment settings for running the Flask application on Databricks Apps platform. + +- **requirements.txt** - Python dependencies file listing all required packages (Flask, databricks-sql-connector, databricks-sdk, pandas, MarkupSafe) with pinned versions for consistent deployments. + +- **templates/index.html** - Main HTML template providing the user interface with query input forms and dynamic results display with XSS protection. + +- **static/css/style.css** - Stylesheet containing all visual styling for the application including responsive layout, forms, tables, error messages, and navigation components. + +- **static/js/app.js** - Client-side JavaScript handling form submissions, dynamic query preview updates, and error handling. + + + diff --git a/csrf_safe-using-origin/app.py b/csrf_safe-using-origin/app.py new file mode 100644 index 00000000..4df882f8 --- /dev/null +++ b/csrf_safe-using-origin/app.py @@ -0,0 +1,201 @@ +from flask import Flask, render_template, request, jsonify +from databricks import sql +from databricks.sdk.core import Config +from databricks.sdk import WorkspaceClient +from databricks.sdk.service.sql import StatementParameterListItem +from markupsafe import escape +import os +import re + +app = Flask(__name__) + +# Initialize Databricks Config - automatically detects DATABRICKS_CLIENT_ID and DATABRICKS_CLIENT_SECRET +# These environment variables are automatically injected by Databricks Apps +cfg = Config() +w = WorkspaceClient() + +# Configuration from environment variables +SERVER_PORT = int(os.environ.get('SERVER_PORT', 8000)) +APP_URL = os.environ.get('DATABRICKS_APP_URL') +CORS_ENABLE = os.environ.get('CORS_ENABLE', 'false').lower() == 'true' + +# Databricks warehouse configuration +DATABRICKS_WAREHOUSE_ID = os.environ.get('DATABRICKS_WAREHOUSE_ID') + +# Validate required environment variables +if not DATABRICKS_WAREHOUSE_ID: + raise ValueError("DATABRICKS_WAREHOUSE_ID environment variable is required") +if not APP_URL: + raise ValueError("DATABRICKS_APP_URL environment variable is required") + + +# Regex pattern for origin validation only +ORIGIN_REGEX = re.compile(r'^[a-zA-Z0-9\-.]+(:[0-9]{1,5})?$') + +def validate_origin(): + """ + Validate Origin header for CSRF protection. + Returns: (is_valid, error_message) + """ + origin = request.headers.get('Origin') + + # If Origin header is not present, empty, or null - ALLOW + if origin is None or origin == '' or origin == 'null': + return True, None + + # Origin is present - validate it + # First check if origin contains only valid characters + if not ORIGIN_REGEX.match(origin): + return False, "Invalid Origin header format - contains invalid characters" + + # Check if it starts with https:// + if not origin.startswith('https://'): + return False, "Origin must use HTTPS protocol" + + # Normalize both values to lowercase for comparison + origin_lower = origin.lower() + expected_origin_lower = APP_URL.lower() + + # Compare the origins (without considering ports or trailing slashes) + # Remove trailing slashes for comparison + origin_normalized = origin_lower.rstrip('/') + expected_normalized = expected_origin_lower.rstrip('/') + + if origin_normalized == expected_normalized: + return True, None + else: + return False, f"Origin header mismatch. Expected: {APP_URL}, Got: {origin}" + +app.config['DEBUG'] = False + + +@app.before_request +def csrf_protect(): + """Check Origin header for all requests""" + is_valid, error_message = validate_origin() + if not is_valid: + return jsonify({'error': escape(error_message)}), 403 + +@app.after_request +def set_security_headers(response): + # CORS headers (optional, controlled by CORS_ENABLE environment variable) + if CORS_ENABLE: + response.headers['Access-Control-Allow-Origin'] = APP_URL + response.headers['Access-Control-Allow-Credentials'] = 'false' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Requested-With' + + # Content Security Policy + response.headers['Content-Security-Policy'] = ( + "default-src https:; " + "script-src https:; " + "style-src 'self' 'unsafe-inline'; " + "img-src https: data:; " + "font-src https: data:; " + "object-src 'none'; " + "base-uri 'self'; " + "frame-ancestors 'none';" + ) + + # Other security headers + response.headers['X-Content-Type-Options'] = 'nosniff' + + return response + +def execute_sql_query_with_params(pickup_zip_value, user_token): + """ + Execute SQL query using OBO (On-Behalf-Of-User) authorization with parameterized query. + + Uses StatementParameterListItem for SQL injection protection. + The query uses a fixed table (samples.nyctaxi.trips) and parameterizes user input. + + The user's access token is passed to act on behalf of the user. + """ + if not user_token: + raise ValueError("User token is required for SQL execution") + + # Create parameterized query - user input is safely passed as parameter + query = "SELECT * FROM samples.nyctaxi.trips WHERE pickup_zip = :pickup_zip LIMIT 10" + + # Use StatementParameterListItem for safe parameterization + param_list = [StatementParameterListItem(name="pickup_zip", value=str(pickup_zip_value))] + + # Execute using Databricks SDK with user token for OBO + # Note: We need to use sql.connect for OBO with user token + conn = sql.connect( + server_hostname=cfg.host, + http_path=f"/sql/1.0/warehouses/{DATABRICKS_WAREHOUSE_ID}", + access_token=user_token + ) + + with conn.cursor() as cursor: + # Note: databricks-sql-connector does not support StatementParameterListItem directly + # We use standard parameter binding with ? placeholder + parameterized_query = "SELECT * FROM samples.nyctaxi.trips WHERE pickup_zip = ? LIMIT 10" + cursor.execute(parameterized_query, [str(pickup_zip_value)]) + df = cursor.fetchall_arrow().to_pandas() + + if len(df) > 0: + return { + 'columns': [escape(str(col)) for col in df.columns.tolist()], + 'rows': [[escape(str(cell)) for cell in row] for row in df.values.tolist()], + 'row_count': len(df), + 'has_data': True, + } + else: + return { + 'columns': [escape(str(col)) for col in df.columns.tolist()] if len(df.columns) > 0 else [], + 'rows': [], + 'row_count': 0, + 'has_data': False, + } + +@app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) +def index(): + headers = request.headers + user = escape(headers.get('X-Forwarded-Preferred-Username', 'Unknown User')) + user_token = headers.get('x-forwarded-access-token') + + result_data = None + parsed_data = None + error_message = None + query_info = None + + # Handle different HTTP methods + if request.method == 'POST': + pickup_zip = request.form.get('pickup_zip', '').strip() + + if not pickup_zip: + error_message = "Pickup ZIP code is required." + elif not user_token: + error_message = "User token is required for query execution." + else: + try: + # Execute parameterized query (SQL injection safe) + query_display = f"SELECT * FROM samples.nyctaxi.trips WHERE pickup_zip = '{pickup_zip}' LIMIT 10" + parsed_data = execute_sql_query_with_params(pickup_zip, user_token) + + query_info = { + 'query': escape(query_display), + 'status': 'executed', + 'result_count': parsed_data['row_count'] if parsed_data else 0, + 'has_data': parsed_data['has_data'] if parsed_data else False + } + except Exception as e: + error_message = "Query execution failed. Please check your inputs and permissions." + + elif request.method in ['PUT', 'DELETE', 'PATCH']: + # Handle other state-changing methods + error_message = f"{request.method} method not implemented for this endpoint." + + # For all methods, return the template + return render_template('index.html', + user=user, + user_token=user_token, + result_data=result_data, + parsed_data=parsed_data, + error_message=error_message, + query_info=query_info) + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=SERVER_PORT, debug=False) \ No newline at end of file diff --git a/csrf_safe-using-origin/app.yaml b/csrf_safe-using-origin/app.yaml new file mode 100644 index 00000000..08eaadad --- /dev/null +++ b/csrf_safe-using-origin/app.yaml @@ -0,0 +1,6 @@ +display_name: "csrf-using-origin" +env: + - name: "SERVER_PORT" + value: "8000" + - name: "DATABRICKS_WAREHOUSE_ID" + valueFrom: "sql-warehouse" \ No newline at end of file diff --git a/csrf_safe-using-origin/requirements.txt b/csrf_safe-using-origin/requirements.txt new file mode 100644 index 00000000..20f9f69f --- /dev/null +++ b/csrf_safe-using-origin/requirements.txt @@ -0,0 +1,2 @@ +pandas==2.3.3 +MarkupSafe==3.0.3 diff --git a/csrf_safe-using-origin/static/css/style.css b/csrf_safe-using-origin/static/css/style.css new file mode 100644 index 00000000..874f0dbc --- /dev/null +++ b/csrf_safe-using-origin/static/css/style.css @@ -0,0 +1,294 @@ +/* Secure Flask App Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; +} + +.navbar { + background: #2c3e50; + padding: 1rem 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-brand a { + color: white; + text-decoration: none; + font-size: 1.5rem; + font-weight: bold; + margin-left: 2rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.hero { + text-align: center; + padding: 3rem 0; + background: #ecf0f1; + margin-bottom: 2rem; +} + +.user-info { + margin-top: 1rem; + padding: 1rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.user-info h2 { + color: #2c3e50; + margin-bottom: 0.5rem; +} + +.token-display { + font-family: monospace; + background: #f8f9fa; + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid #ddd; +} + +.token-missing { + font-family: monospace; + background: #f8d7da; + color: #721c24; + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid #f5c6cb; +} + +.query-section { + margin-bottom: 3rem; + padding: 2rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.query-preview { + background: #f8f9fa; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #3498db; + margin-bottom: 1.5rem; + font-family: monospace; + font-size: 1.1rem; +} + +.query-preview code { + background: #e9ecef; + padding: 0.2rem 0.4rem; + border-radius: 3px; + color: #495057; +} + +.query-info { + background: #d4edda; + color: #155724; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #28a745; + margin-bottom: 1.5rem; +} + +.query-info h3 { + margin-bottom: 0.5rem; + color: #155724; +} + +.query-info p { + margin-bottom: 0.5rem; +} + +.query-info code { + background: rgba(0, 0, 0, 0.1); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: monospace; +} + +.status-success { + color: #28a745; + font-weight: bold; +} + +.no-data-message { + background: #fff3cd; + color: #856404; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #ffc107; + margin-top: 1rem; +} + +.no-data-message p { + margin-bottom: 0.5rem; +} + +.no-data-message ul { + margin-left: 1.5rem; +} + +.no-data-message li { + margin-bottom: 0.25rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; +} + +.form-group input, .form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-family: inherit; +} + +.form-group textarea { + resize: vertical; + min-height: 120px; +} + +.btn-primary { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + background: #3498db; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #2980b9; +} + +.btn-primary:disabled { + background: #95a5a6; + cursor: not-allowed; + opacity: 0.6; +} + +.results-section { + margin-top: 2rem; + padding: 2rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.results-container { + overflow-x: auto; +} + +.results-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} + +.results-table th, .results-table td { + padding: 0.75rem; + border: 1px solid #ddd; + text-align: left; +} + +.results-table th { + background: #f8f9fa; + font-weight: bold; +} + +.results-table tr:nth-child(even) { + background: #f9f9f9; +} + +.results-table tr:hover { + background: #f0f0f0; +} + +.error-message { + background: #e74c3c; + color: white; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +.error-message h3 { + margin-bottom: 0.5rem; + color: white; +} + +.debug-info { + background: rgba(255, 255, 255, 0.1); + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; + border-left: 4px solid #f39c12; +} + +.debug-info h4 { + margin-bottom: 0.5rem; + color: #f39c12; +} + +.debug-info p { + margin-bottom: 0.5rem; +} + +.debug-info code { + background: rgba(0, 0, 0, 0.2); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: monospace; +} + +.debug-info details { + margin-top: 1rem; +} + +.debug-info summary { + cursor: pointer; + font-weight: bold; + color: #f39c12; + margin-bottom: 0.5rem; +} + +.traceback { + background: rgba(0, 0, 0, 0.2); + padding: 1rem; + border-radius: 4px; + font-family: monospace; + font-size: 0.9rem; + white-space: pre-wrap; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; +} + +footer { + background: #2c3e50; + color: white; + text-align: center; + padding: 2rem 0; +} \ No newline at end of file diff --git a/csrf_safe-using-origin/static/js/app.js b/csrf_safe-using-origin/static/js/app.js new file mode 100644 index 00000000..2c143799 --- /dev/null +++ b/csrf_safe-using-origin/static/js/app.js @@ -0,0 +1,74 @@ +// Function to handle form submission +function handleFormSubmit(e) { + e.preventDefault(); + + const formData = new FormData(this); + const data = new URLSearchParams(); + + for (let [key, value] of formData.entries()) { + data.append(key, value); + } + + fetch(window.location.href, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data.toString() + }) + .then(response => { + if (response.ok) { + return response.text(); + } else { + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json().then(err => { + throw new Error(err.error || 'Request failed'); + }); + } else { + return response.text().then(html => { + throw new Error('Wrong Query/Do not have access to table'); + }); + } + } + }) + .then(html => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const newMain = tempDiv.querySelector('main'); + const currentMain = document.querySelector('main'); + + if (newMain && currentMain) { + currentMain.innerHTML = newMain.innerHTML; + } + + attachEventListeners(); + }) + .catch(error => { + alert('Error: ' + error.message); + }); +} + +// Function to attach event listeners +function attachEventListeners() { + const form = document.getElementById('query-form'); + if (form) { + form.addEventListener('submit', handleFormSubmit); + } + + const zipInput = document.getElementById('pickup_zip'); + if (zipInput) { + zipInput.addEventListener('input', function() { + // Use textContent instead of innerHTML for XSS protection + const preview = document.getElementById('zip-preview'); + if (preview) { + preview.textContent = "'" + (this.value || 'value') + "'"; + } + }); + } +} + +// Initial setup +document.addEventListener('DOMContentLoaded', attachEventListeners); \ No newline at end of file diff --git a/csrf_safe-using-origin/templates/index.html b/csrf_safe-using-origin/templates/index.html new file mode 100644 index 00000000..4372995d --- /dev/null +++ b/csrf_safe-using-origin/templates/index.html @@ -0,0 +1,117 @@ + + + + + + + Secure Databricks Query Tool + + + +
+ +
+ +
+
+

Secure Databricks Query Tool

+ +
+ +
+

Execute SQL Query

+

Query: SELECT * FROM samples.nyctaxi.trips WHERE pickup_zip = 'value' LIMIT 10

+ + {% if error_message %} +
+

Error

+

{{ error_message | e }}

+
+ {% endif %} + + {% if query_info %} +
+

Query Execution Results

+

Query: {{ query_info.query | e }}

+

Status: {{ query_info.status | e }}

+

Rows Returned: {{ query_info.result_count | e }}

+ + {% if not query_info.has_data %} +
+

No data found. This could mean:

+
    +
  • No trips found with the specified pickup ZIP code
  • +
  • You don't have permission to access this data
  • +
  • The user token doesn't have sufficient privileges
  • +
+
+ {% endif %} +
+ {% endif %} + +
+
+ + + Query NYC taxi trips by pickup ZIP code (safely parameterized) +
+ + +
+
+ + {% if parsed_data and parsed_data.has_data %} +
+

Query Results ({{ parsed_data.row_count | e }} rows)

+
+ + + + {% for column in parsed_data.columns %} + + {% endfor %} + + + + {% for row in parsed_data.rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ column | e }}
{{ cell | e }}
+
+
+ {% endif %} +
+ + + + + + \ No newline at end of file diff --git a/flask-secure-app-obo/README.md b/flask-secure-app-obo/README.md new file mode 100644 index 00000000..f90b6bda --- /dev/null +++ b/flask-secure-app-obo/README.md @@ -0,0 +1,116 @@ +# Secure Databricks Flask App + +This application demonstrates best practices for building secure Databricks Flask apps with comprehensive security features. + +## Security Features + +### 1. OBO (On-Behalf-Of-User Authorization) +**On-Behalf-Of-User (OBO) Authorization** enables the application to execute SQL queries using the authenticated user's access token rather than a service account token. This ensures that all database operations are performed with the user's permissions, maintaining proper access control and audit trails while the app acts as an intermediary. + +### 2. CSP (Content Security Policy) +The application enforces the following Content Security Policy to prevent XSS and injection attacks: + +``` +Content-Security-Policy: default-src https:; script-src https:; style-src 'self' 'unsafe-inline'; img-src https: data:; font-src https: data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; +``` + +**Policy Details:** +- `default-src https:` - Only allow HTTPS resources by default +- `script-src https:` - Only allow scripts from HTTPS sources +- `style-src 'self' 'unsafe-inline'` - Allow styles from same origin and inline styles +- `img-src https: data:` - Allow images from HTTPS and data URIs +- `font-src https: data:` - Allow fonts from HTTPS and data URIs +- `object-src 'none'` - Block all plugins (Flash, Java, etc.) +- `base-uri 'self'` - Restrict base tag to same origin +- `frame-ancestors 'none'` - Prevent the page from being embedded in iframes (clickjacking protection) + +### 3. CORS (Cross-Origin Resource Sharing) +CORS headers are **disabled by default** for security. To enable CORS support, add the following environment variable in your `app.yaml`: + +```yaml +env: + - name: "CORS_ENABLE" + value: "true" +``` + +**Example `app.yaml` with CORS enabled:** +```yaml +display_name: "Secure_Flask_App" +env: + - name: "SERVER_PORT" + value: "8000" + - name: "DATABRICKS_WAREHOUSE_ID" + valueFrom: "sql-warehouse" + - name: "CORS_ENABLE" + value: "true" +``` + +When enabled, the following CORS headers will be set: +- `Access-Control-Allow-Origin`: Configured app URL +- `Access-Control-Allow-Credentials`: false +- `Access-Control-Allow-Methods`: GET, POST, PUT, DELETE, PATCH +- `Access-Control-Allow-Headers`: Content-Type, X-Requested-With + +## Additional Security Features + +- **SQL Injection Protection**: Uses Databricks `IDENTIFIER` clause for safe parameterization of table and column names. The IDENTIFIER clause prevents SQL injection by interpreting user input as SQL identifiers (table/column names) in a safe manner, ensuring malicious SQL code cannot be injected through user inputs. + + Reference: [Databricks IDENTIFIER clause documentation](https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-names-identifier-clause) + +- **CSRF Protection**: Built-in CSRF token validation using Flask-WTF + +- **Input Sanitization**: All user inputs and query results are escaped using MarkupSafe to prevent XSS attacks + +- **Secure Token Handling**: User access tokens are handled securely via headers + +**Note:** App creators can extend this application with additional security features based on their specific requirements, such as rate limiting, user audit logging, or custom authentication mechanisms. + +## Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `DATABRICKS_HOST` | Your Databricks workspace hostname | Auto | +| `DATABRICKS_WAREHOUSE_ID` | SQL warehouse ID | Yes | +| `DATABRICKS_APP_NAME` | Name of your Databricks app | Auto | +| `DATABRICKS_APP_URL` | URL where your app is accessible | Auto | +| `SERVER_PORT` | Port to run the application (default: 8000) | No | +| `CORS_ENABLE` | Enable CORS headers (default: false) | No | + +## Configuration + +1. If you want to add more libraries add them in requirements.txt + +2. Configure your `app.yaml` with required environment variables + +## File Structure + +``` +secure-flask-app/ +├── app.py +├── app.yaml +├── requirements.txt +├── README.md +├── static/ +│ ├── css/ +│ │ └── style.css +│ └── js/ +│ └── app.js +└── templates/ + └── index.html +``` + +### File Descriptions + +- **app.py** - Main Flask application file containing route handlers, security header configurations, CSRF protection setup, SQL injection protection using IDENTIFIER clause, and SQL query execution logic with OBO (On-Behalf-Of-User) token handling. + +- **app.yaml** - Databricks app configuration file that defines environment variables and deployment settings for running the Flask application on Databricks Apps platform. + +- **requirements.txt** - Python dependencies file listing all required packages (Flask-WTF, Flask-CORS, MarkupSafe, pandas) with pinned versions for consistent deployments. + +- **templates/index.html** - Main HTML template providing the user interface with CSRF token embedding, query input forms, and dynamic results display with proper XSS protection. + +- **static/css/style.css** - Stylesheet containing all visual styling for the application including responsive layout, forms, tables, error messages, and navigation components. + +- **static/js/app.js** - Client-side JavaScript handling AJAX form submissions, CSRF token management, dynamic query preview updates, and error handling without page reloads. + + diff --git a/flask-secure-app-obo/app.py b/flask-secure-app-obo/app.py new file mode 100644 index 00000000..85494a54 --- /dev/null +++ b/flask-secure-app-obo/app.py @@ -0,0 +1,198 @@ +from flask import Flask, render_template, request, jsonify +from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError +from databricks import sql +from databricks.sdk.core import Config +from databricks.sdk import WorkspaceClient +from markupsafe import escape +import os +import pandas as pd + +app = Flask(__name__) + +# Ensure environment variable is set correctly +SERVER_PORT = int(os.environ.get('SERVER_PORT', 8000)) +APP_NAME = os.environ.get('DATABRICKS_APP_NAME') +DATABRICKS_HOST = os.environ.get('DATABRICKS_HOST') +DATABRICKS_WAREHOUSE_ID = os.environ.get('DATABRICKS_WAREHOUSE_ID') +APP_URL = os.environ.get('DATABRICKS_APP_URL') +CORS_ENABLE = os.environ.get('CORS_ENABLE', 'false').lower() == 'true' + +# Validate required environment variables +if not DATABRICKS_HOST: + raise ValueError("DATABRICKS_HOST environment variable is required") +if not DATABRICKS_WAREHOUSE_ID: + raise ValueError("WAREHOUSE_HTTP_PATH environment variable is required") +if not APP_NAME: + raise ValueError("APP_NAME environment variable is required") +if not APP_URL: + raise ValueError("DATABRICKS_APP_URL environment variable is required") + +# Initialize Databricks config and WorkspaceClient +cfg = Config() +w = WorkspaceClient() + +def get_or_create_csrf_key(): + # Option 1: Using Databricks Secrets (Recommended for production) + # This approach stores the CSRF key securely in Databricks secrets + app_name = os.environ.get('DATABRICKS_APP_NAME') + scope = f"{app_name}_secrets" + + try: + return w.secrets.get_secret(scope=scope, key="csrf_key") + except: + new_key = os.urandom(64).hex() + try: + w.secrets.put_secret(scope=scope, key="csrf_key", string_value=new_key) + except: + pass + return new_key + + # Option 2: Without Databricks Secrets (Simple approach for development/testing) + # Uncomment the lines below and comment out Option 1 above to use this method + # Note: This generates a new key on each restart, which will invalidate existing sessions + # return os.urandom(64).hex() + +app.config['SECRET_KEY'] = get_or_create_csrf_key() +app.config['WTF_CSRF_ENABLED'] = True +app.config['WTF_CSRF_TIME_LIMIT'] = 3600 +app.config['WTF_CSRF_SSL_STRICT'] = True # Require HTTPS for CSRF tokens +app.config['DEBUG'] = False + +# Initialize CSRF protection +csrf = CSRFProtect(app) + + +@app.errorhandler(CSRFError) +def handle_csrf_error(e): + """Handle CSRF validation errors""" + return jsonify({'error': 'CSRF token missing or invalid. Please refresh the page.'}), 400 + +@app.after_request +def set_security_headers(response): + # CORS headers (optional, controlled by CORS_ENABLE environment variable) + if CORS_ENABLE: + response.headers['Access-Control-Allow-Origin'] = APP_URL + response.headers['Access-Control-Allow-Credentials'] = 'false' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Requested-With' + + # Content Security Policy + response.headers['Content-Security-Policy'] = ( + "default-src https:; " + "script-src https:; " + "style-src 'self' 'unsafe-inline'; " + "img-src https: data:; " + "font-src https: data:; " + "object-src 'none'; " + "base-uri 'self'; " + "frame-ancestors 'none';" + ) + + # Other security headers + response.headers['X-Content-Type-Options'] = 'nosniff' + + return response + +def execute_sql_with_token(column_name, table_name, user_token): + """ + Execute SQL query using OBO (On-Behalf-Of-User) authorization with SQL injection protection. + + Uses Databricks IDENTIFIER clause for safe parameterization of table and column names. + The IDENTIFIER clause interprets string parameters as SQL identifiers (table/column names) + in a SQL injection-safe manner. + + The user's access token is passed to execute queries on behalf of the user, + maintaining proper access control and audit trails. + + Reference: + - Auth: https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth + - IDENTIFIER: https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-names-identifier-clause + """ + if not user_token: + raise ValueError("User token is required for SQL execution") + + conn = sql.connect( + server_hostname=DATABRICKS_HOST, + http_path=f"/sql/1.0/warehouses/{DATABRICKS_WAREHOUSE_ID}", + access_token=user_token + ) + + with conn.cursor() as cursor: + # Use IDENTIFIER clause for SQL injection-safe parameterization of identifiers + # Parameters are passed as a dictionary with named parameters + query = "SELECT IDENTIFIER(:column_name) FROM IDENTIFIER(:table_name) LIMIT 10" + parameters = {"column_name": column_name, "table_name": table_name} + + cursor.execute(query, parameters) + df = cursor.fetchall_arrow().to_pandas() + + if len(df) > 0: + return { + 'columns': [escape(str(col)) for col in df.columns.tolist()], + 'rows': [[escape(str(cell)) for cell in row] for row in df.values.tolist()], + 'row_count': len(df), + 'has_data': True, + 'dataframe': df + } + else: + return { + 'columns': [escape(str(col)) for col in df.columns.tolist()] if len(df.columns) > 0 else [], + 'rows': [], + 'row_count': 0, + 'has_data': False, + 'dataframe': df + } + +@app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) +def index(): + headers = request.headers + user = escape(headers.get('X-Forwarded-Preferred-Username', 'Unknown User')) + user_token = headers.get('x-forwarded-access-token') + + result_data = None + parsed_data = None + error_message = None + query_info = None + + # Handle different HTTP methods + if request.method == 'POST': + + column_name = request.form.get('column_name', '').strip() + table_name = request.form.get('table_name', '').strip() + + if not column_name or not table_name: + error_message = "Both column name and table name are required." + elif not user_token: + error_message = "User token is required for query execution." + else: + try: + # Execute query with IDENTIFIER clause for SQL injection protection + parsed_data = execute_sql_with_token(column_name, table_name, user_token) + + # Display query for user reference (HTML-escaped for XSS protection) + query_display = f"SELECT {escape(column_name)} FROM {escape(table_name)} LIMIT 10" + + query_info = { + 'query': query_display, + 'status': 'executed', + 'result_count': parsed_data['row_count'] if parsed_data else 0, + 'has_data': parsed_data['has_data'] if parsed_data else False + } + except Exception as e: + error_message = f"Query execution failed: {escape(str(e))}" + + elif request.method in ['PUT', 'DELETE', 'PATCH']: + # Handle other state-changing methods + error_message = f"{request.method} method not implemented for this endpoint." + + # For all methods, return the template with CSRF token + return render_template('index.html', + user=user, + user_token=user_token, + result_data=result_data, + parsed_data=parsed_data, + error_message=error_message, + query_info=query_info) + +if __name__ == '__main__': + app.run(debug=True, host="0.0.0.0", port=SERVER_PORT) \ No newline at end of file diff --git a/flask-secure-app-obo/app.yaml b/flask-secure-app-obo/app.yaml new file mode 100644 index 00000000..6017b1aa --- /dev/null +++ b/flask-secure-app-obo/app.yaml @@ -0,0 +1,6 @@ +display_name: "Secure_Flask_App" +env: + - name: "SERVER_PORT" + value: "8000" + - name: "DATABRICKS_WAREHOUSE_ID" + valueFrom: "sql-warehouse" \ No newline at end of file diff --git a/flask-secure-app-obo/requirements.txt b/flask-secure-app-obo/requirements.txt new file mode 100644 index 00000000..9a664ebc --- /dev/null +++ b/flask-secure-app-obo/requirements.txt @@ -0,0 +1,4 @@ +Flask-WTF==1.2.2 +Flask-CORS==6.0.1 +MarkupSafe==3.0.3 +pandas==2.3.3 diff --git a/flask-secure-app-obo/static/css/style.css b/flask-secure-app-obo/static/css/style.css new file mode 100644 index 00000000..874f0dbc --- /dev/null +++ b/flask-secure-app-obo/static/css/style.css @@ -0,0 +1,294 @@ +/* Secure Flask App Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; +} + +.navbar { + background: #2c3e50; + padding: 1rem 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-brand a { + color: white; + text-decoration: none; + font-size: 1.5rem; + font-weight: bold; + margin-left: 2rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.hero { + text-align: center; + padding: 3rem 0; + background: #ecf0f1; + margin-bottom: 2rem; +} + +.user-info { + margin-top: 1rem; + padding: 1rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.user-info h2 { + color: #2c3e50; + margin-bottom: 0.5rem; +} + +.token-display { + font-family: monospace; + background: #f8f9fa; + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid #ddd; +} + +.token-missing { + font-family: monospace; + background: #f8d7da; + color: #721c24; + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid #f5c6cb; +} + +.query-section { + margin-bottom: 3rem; + padding: 2rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.query-preview { + background: #f8f9fa; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #3498db; + margin-bottom: 1.5rem; + font-family: monospace; + font-size: 1.1rem; +} + +.query-preview code { + background: #e9ecef; + padding: 0.2rem 0.4rem; + border-radius: 3px; + color: #495057; +} + +.query-info { + background: #d4edda; + color: #155724; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #28a745; + margin-bottom: 1.5rem; +} + +.query-info h3 { + margin-bottom: 0.5rem; + color: #155724; +} + +.query-info p { + margin-bottom: 0.5rem; +} + +.query-info code { + background: rgba(0, 0, 0, 0.1); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: monospace; +} + +.status-success { + color: #28a745; + font-weight: bold; +} + +.no-data-message { + background: #fff3cd; + color: #856404; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #ffc107; + margin-top: 1rem; +} + +.no-data-message p { + margin-bottom: 0.5rem; +} + +.no-data-message ul { + margin-left: 1.5rem; +} + +.no-data-message li { + margin-bottom: 0.25rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; +} + +.form-group input, .form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-family: inherit; +} + +.form-group textarea { + resize: vertical; + min-height: 120px; +} + +.btn-primary { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + background: #3498db; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #2980b9; +} + +.btn-primary:disabled { + background: #95a5a6; + cursor: not-allowed; + opacity: 0.6; +} + +.results-section { + margin-top: 2rem; + padding: 2rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.results-container { + overflow-x: auto; +} + +.results-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} + +.results-table th, .results-table td { + padding: 0.75rem; + border: 1px solid #ddd; + text-align: left; +} + +.results-table th { + background: #f8f9fa; + font-weight: bold; +} + +.results-table tr:nth-child(even) { + background: #f9f9f9; +} + +.results-table tr:hover { + background: #f0f0f0; +} + +.error-message { + background: #e74c3c; + color: white; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +.error-message h3 { + margin-bottom: 0.5rem; + color: white; +} + +.debug-info { + background: rgba(255, 255, 255, 0.1); + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; + border-left: 4px solid #f39c12; +} + +.debug-info h4 { + margin-bottom: 0.5rem; + color: #f39c12; +} + +.debug-info p { + margin-bottom: 0.5rem; +} + +.debug-info code { + background: rgba(0, 0, 0, 0.2); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: monospace; +} + +.debug-info details { + margin-top: 1rem; +} + +.debug-info summary { + cursor: pointer; + font-weight: bold; + color: #f39c12; + margin-bottom: 0.5rem; +} + +.traceback { + background: rgba(0, 0, 0, 0.2); + padding: 1rem; + border-radius: 4px; + font-family: monospace; + font-size: 0.9rem; + white-space: pre-wrap; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; +} + +footer { + background: #2c3e50; + color: white; + text-align: center; + padding: 2rem 0; +} \ No newline at end of file diff --git a/flask-secure-app-obo/static/js/app.js b/flask-secure-app-obo/static/js/app.js new file mode 100644 index 00000000..94dfdea2 --- /dev/null +++ b/flask-secure-app-obo/static/js/app.js @@ -0,0 +1,97 @@ +// Function to get current CSRF token +function getCurrentCsrfToken() { + const metaTag = document.querySelector('meta[name="csrf-token"]'); + return metaTag ? metaTag.getAttribute('content') : ''; +} + +// Function to handle form submission +function handleFormSubmit(e) { + e.preventDefault(); + + const currentToken = getCurrentCsrfToken(); + const formData = new FormData(this); + const data = new URLSearchParams(); + + for (let [key, value] of formData.entries()) { + data.append(key, value); + } + + fetch(window.location.href, { + method: 'POST', + headers: { + 'X-CSRFToken': currentToken, + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data.toString() + }) + .then(response => { + if (response.ok) { + return response.text(); + } else { + // Check if response is JSON or HTML + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json().then(err => { + throw new Error(err.error || 'Request failed'); + }); + } else { + // Server returned HTML error page (like 500 error) + return response.text().then(html => { + throw new Error('Wrong Query/Do not have access to table'); + }); + } + } + }) + .then(html => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const newMain = tempDiv.querySelector('main'); + const currentMain = document.querySelector('main'); + + if (newMain && currentMain) { + currentMain.innerHTML = newMain.innerHTML; + } + + // Update CSRF token if changed + const newCsrfToken = tempDiv.querySelector('meta[name="csrf-token"]'); + if (newCsrfToken) { + const newTokenValue = newCsrfToken.getAttribute('content'); + const currentCsrfToken = document.querySelector('meta[name="csrf-token"]'); + if (currentCsrfToken) { + currentCsrfToken.setAttribute('content', newTokenValue); + } + } + + attachEventListeners(); + }) + .catch(error => { + alert('Error: ' + error.message); + }); +} + +// Function to attach event listeners +function attachEventListeners() { + const form = document.getElementById('query-form'); + if (form) { + form.addEventListener('submit', handleFormSubmit); + } + + const columnInput = document.getElementById('column_name'); + if (columnInput) { + columnInput.addEventListener('input', function() { + document.getElementById('column-preview').textContent = this.value || 'column'; + }); + } + + const tableInput = document.getElementById('table_name'); + if (tableInput) { + tableInput.addEventListener('input', function() { + document.getElementById('table-preview').textContent = this.value || 'table'; + }); + } +} + +// Initial setup +document.addEventListener('DOMContentLoaded', attachEventListeners); \ No newline at end of file diff --git a/flask-secure-app-obo/templates/index.html b/flask-secure-app-obo/templates/index.html new file mode 100644 index 00000000..ce19b52d --- /dev/null +++ b/flask-secure-app-obo/templates/index.html @@ -0,0 +1,120 @@ + + + + + + Secure Databricks Query Tool + + + + +
+ +
+ +
+
+

Secure Databricks Query Tool

+ +
+ +
+

Execute SQL Query

+

Query: SELECT column FROM table

+ + {% if error_message %} +
+

Error

+

{{ error_message }}

+
+ {% endif %} + + {% if query_info %} +
+

Query Execution Results

+

Query: {{ query_info.query }}

+

Status: {{ query_info.status }}

+

Rows Returned: {{ query_info.result_count }}

+ + {% if not query_info.has_data %} +
+

No data found. This could mean:

+
    +
  • The table exists but has no data
  • +
  • The column name doesn't exist in the table
  • +
  • The table name is incorrect
  • +
  • You don't have permission to access this data
  • +
  • The user token doesn't have sufficient privileges
  • +
+
+ {% endif %} +
+ {% endif %} + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + {% if parsed_data and parsed_data.has_data %} +
+

Query Results ({{ parsed_data.row_count }} rows)

+
+ + + + {% for column in parsed_data.columns %} + + {% endfor %} + + + + {% for row in parsed_data.rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ cell }}
+
+
+ {% endif %} +
+ + + + + + \ No newline at end of file diff --git a/flask-secure-app-sp/README.md b/flask-secure-app-sp/README.md new file mode 100644 index 00000000..e35789b4 --- /dev/null +++ b/flask-secure-app-sp/README.md @@ -0,0 +1,165 @@ +# Secure Databricks Flask App + +This application demonstrates best practices for building secure Databricks Flask apps with comprehensive security features. + +## Security Features + +### 1. App Authorization (Service Principal) +**App Authorization** uses a dedicated service principal that Databricks automatically provisions for each app. When you create a Databricks App, Databricks automatically injects the service principal credentials (`DATABRICKS_CLIENT_ID` and `DATABRICKS_CLIENT_SECRET`) into the app's environment. The app uses these credentials to authenticate via OAuth 2.0 and access Databricks resources with the permissions granted to the service principal. This ensures secure, auditable access control for all app operations. + +Reference: [Databricks Apps Authorization Documentation](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth) + +### 2. CSP (Content Security Policy) +The application enforces the following Content Security Policy to prevent XSS and injection attacks: + +``` +Content-Security-Policy: default-src https:; script-src https:; style-src 'self' 'unsafe-inline'; img-src https: data:; font-src https: data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; +``` + +**Policy Details:** +- `default-src https:` - Only allow HTTPS resources by default +- `script-src https:` - Only allow scripts from HTTPS sources +- `style-src 'self' 'unsafe-inline'` - Allow styles from same origin and inline styles +- `img-src https: data:` - Allow images from HTTPS and data URIs +- `font-src https: data:` - Allow fonts from HTTPS and data URIs +- `object-src 'none'` - Block all plugins (Flash, Java, etc.) +- `base-uri 'self'` - Restrict base tag to same origin +- `frame-ancestors 'none'` - Prevent the page from being embedded in iframes (clickjacking protection) + +### 3. CORS (Cross-Origin Resource Sharing) +CORS headers are **disabled by default** for security. To enable CORS support, add the following environment variable in your `app.yaml`: + +```yaml +env: + - name: "CORS_ENABLE" + value: "true" +``` + +**Example `app.yaml` with CORS enabled:** +```yaml +display_name: "Secure_Flask_App" +env: + - name: "SERVER_PORT" + value: "8000" + - name: "DATABRICKS_WAREHOUSE_ID" + valueFrom: "sql-warehouse" + - name: "SQL_AUTHORIZED_USERS" + value: "sql_user1@email.com,sql_user2@email.com" + - name: "CORS_ENABLE" + value: "true" +``` + +When enabled, the following CORS headers will be set: +- `Access-Control-Allow-Origin`: Configured app URL +- `Access-Control-Allow-Credentials`: false +- `Access-Control-Allow-Methods`: GET, POST, PUT, DELETE, PATCH +- `Access-Control-Allow-Headers`: Content-Type, X-Requested-With + +## Additional Security Features + +- **SQL Injection Protection**: Uses Databricks `IDENTIFIER` clause for safe parameterization of table and column names. The IDENTIFIER clause prevents SQL injection by interpreting user input as SQL identifiers (table/column names) in a safe manner, ensuring malicious SQL code cannot be injected through user inputs. + ```python + # Safe query using IDENTIFIER clause + query = "SELECT IDENTIFIER(:column_name) FROM IDENTIFIER(:table_name) LIMIT 10" + cursor.execute(query, {"column_name": column_name, "table_name": table_name}) + ``` + Reference: [Databricks IDENTIFIER clause documentation](https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-names-identifier-clause) + +- **CSRF Protection**: Built-in CSRF token validation using Flask-WTF + +- **Input Sanitization**: All user inputs and query results are escaped using MarkupSafe to prevent XSS attacks + +- **X-Content-Type-Options**: Set to `nosniff` to prevent MIME-sniffing attacks + +- **Secure Credential Management**: Service Principal credentials are managed via environment variables or Databricks SDK configuration + +- **SQL Authorization Control**: Basic user authorization example that restricts SQL query execution to a list of authorized users (configured via `SQL_AUTHORIZED_USERS` environment variable) + +**Important Note on Authorization:** The SQL authorization implementation provided in this app is a **simple example** for demonstration purposes. App developers should implement their own Access Control List (ACL) logic based on their specific security requirements. Consider implementing more sophisticated authorization mechanisms such as: +- Integration with enterprise identity management systems (LDAP, Active Directory, etc.) +- Role-Based Access Control (RBAC) +- Dynamic permission checking via Databricks Unity Catalog +- OAuth scopes and fine-grained permissions +- Group-based access control + +App creators can extend this application with additional security features based on their specific requirements, such as rate limiting, user audit logging, or custom authentication mechanisms. + +## Environment Variables + +### Automatically Injected by Databricks Apps + +These variables are automatically set by Databricks when you deploy your app: + +| Variable | Description | Auto-Injected | +|----------|-------------|---------------| +| `DATABRICKS_HOST` | Your Databricks workspace hostname | ✅ Yes | +| `DATABRICKS_CLIENT_ID` | App service principal OAuth client ID | ✅ Yes | +| `DATABRICKS_CLIENT_SECRET` | App service principal OAuth client secret | ✅ Yes | +| `DATABRICKS_APP_NAME` | Name of your Databricks app | ✅ Yes | +| `DATABRICKS_APP_URL` | URL where your app is accessible | ✅ Yes | + +### Required Configuration + +These variables must be configured in your `app.yaml`: + +| Variable | Description | Required | +|----------|-------------|----------| +| `DATABRICKS_WAREHOUSE_ID` | SQL warehouse ID | Yes | +| `SQL_AUTHORIZED_USERS` | Comma-separated list of usernames/emails authorized to execute SQL queries (e.g., `user1@email.com,user2@email.com`) | Yes | +| `SERVER_PORT` | Port to run the application (default: 8000) | No | +| `CORS_ENABLE` | Enable CORS headers (default: false) | No | + +**About `SQL_AUTHORIZED_USERS`:** +This environment variable defines a whitelist of users who are permitted to execute SQL queries through the app. The app validates the current user's identity (obtained from the `X-Forwarded-Preferred-Username` header provided by Databricks) against this list. Users not in the list will receive an "Access denied" error when attempting to run queries. + +Format: Comma-separated list of usernames or email addresses (no spaces around commas recommended for clarity) +Example: `user1@company.com,user2@company.com,admin@company.com` + +This is a basic example implementation. For production environments, consider implementing more sophisticated authorization mechanisms integrated with your enterprise identity management system. + +## Configuration + +1. If you want to add more libraries add them in requirements.txt + +2. Configure your `app.yaml` with required environment variables + +3. **Set Authorized SQL Users**: Update the `SQL_AUTHORIZED_USERS` environment variable in `app.yaml` with a comma-separated list of usernames/emails of users who should have permission to execute SQL queries through the app. + + Example: `"sql_user1@email.com,sql_user2@email.com,admin@company.com"` + + Each user's identity is verified against the `X-Forwarded-Preferred-Username` header provided by Databricks. Users not in this list will receive an "Access denied" error when attempting to execute queries. + + **Note:** This is a basic example implementation. For production use, implement more sophisticated ACL logic tailored to your organization's security requirements. + +## File Structure + +``` +secure-flask-app/ +├── app.py +├── app.yaml +├── requirements.txt +├── README.md +├── static/ +│ ├── css/ +│ │ └── style.css +│ └── js/ +│ └── app.js +└── templates/ + └── index.html +``` + +### File Descriptions + +- **app.py** - Main Flask application file containing route handlers, security header configurations, CSRF protection setup, SQL injection protection using IDENTIFIER clause, and SQL query execution logic with Service Principal authentication. + +- **app.yaml** - Databricks app configuration file that defines environment variables and deployment settings for running the Flask application on Databricks Apps platform. + +- **requirements.txt** - Python dependencies file listing all required packages (Flask-WTF, Flask-CORS, MarkupSafe, pandas) with pinned versions for consistent deployments. + +- **templates/index.html** - Main HTML template providing the user interface with CSRF token embedding, query input forms, and dynamic results display with proper XSS protection. + +- **static/css/style.css** - Stylesheet containing all visual styling for the application including responsive layout, forms, tables, error messages, and navigation components. + +- **static/js/app.js** - Client-side JavaScript handling AJAX form submissions, CSRF token management, dynamic query preview updates, and error handling without page reloads. + + diff --git a/flask-secure-app-sp/app.py b/flask-secure-app-sp/app.py new file mode 100644 index 00000000..012aa6dd --- /dev/null +++ b/flask-secure-app-sp/app.py @@ -0,0 +1,234 @@ +from flask import Flask, render_template, request, jsonify +from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError +from databricks import sql +from databricks.sdk.core import Config +from databricks.sdk import WorkspaceClient +from markupsafe import escape +import os +import pandas as pd + +app = Flask(__name__) + +# Initialize Databricks Config - automatically detects DATABRICKS_CLIENT_ID and DATABRICKS_CLIENT_SECRET +# These environment variables are automatically injected by Databricks Apps +cfg = Config() +w = WorkspaceClient() + +# Application environment variables +SERVER_PORT = int(os.environ.get('SERVER_PORT', 8000)) +APP_NAME = os.environ.get('DATABRICKS_APP_NAME') +APP_URL = os.environ.get('DATABRICKS_APP_URL') +CORS_ENABLE = os.environ.get('CORS_ENABLE', 'false').lower() == 'true' + +# Databricks warehouse configuration +DATABRICKS_WAREHOUSE_ID = os.environ.get('DATABRICKS_WAREHOUSE_ID') + +# SQL Authorized Users configuration +# IMPORTANT: This is a simple example of access control logic. +# App developers should implement their own ACL (Access Control List) logic +# based on their security requirements. This could include: +# - Integration with enterprise identity management systems +# - Role-based access control (RBAC) +# - Dynamic permission checking via Databricks Unity Catalog +# - OAuth scopes and fine-grained permissions +# For production use, consider implementing more sophisticated authorization mechanisms. +SQL_AUTHORIZED_USERS_STR = os.environ.get('SQL_AUTHORIZED_USERS', '') +SQL_AUTHORIZED_USERS = [user.strip() for user in SQL_AUTHORIZED_USERS_STR.split(',') if user.strip()] + +# Validate required environment variables +if not DATABRICKS_WAREHOUSE_ID: + raise ValueError("DATABRICKS_WAREHOUSE_ID environment variable is required") +if not APP_NAME: + raise ValueError("DATABRICKS_APP_NAME environment variable is required") +if not APP_URL: + raise ValueError("DATABRICKS_APP_URL environment variable is required") +if not SQL_AUTHORIZED_USERS: + raise ValueError("SQL_AUTHORIZED_USERS environment variable is required and must contain at least one user") + +def get_or_create_csrf_key(): + # Option 1: Using Databricks Secrets (Recommended for production) + # This approach stores the CSRF key securely in Databricks secrets + app_name = os.environ.get('DATABRICKS_APP_NAME') + scope = f"{app_name}_secrets" + + try: + return w.secrets.get_secret(scope=scope, key="csrf_key") + except: + new_key = os.urandom(64).hex() + try: + w.secrets.put_secret(scope=scope, key="csrf_key", string_value=new_key) + except: + pass + return new_key + + # Option 2: Without Databricks Secrets (Simple approach for development/testing) + # Uncomment the lines below and comment out Option 1 above to use this method + # Note: This generates a new key on each restart, which will invalidate existing sessions + # return os.urandom(64).hex() + +app.config['SECRET_KEY'] = get_or_create_csrf_key() +app.config['WTF_CSRF_ENABLED'] = True +app.config['WTF_CSRF_TIME_LIMIT'] = 3600 +app.config['WTF_CSRF_SSL_STRICT'] = True # Require HTTPS for CSRF tokens +app.config['DEBUG'] = False + +# Initialize CSRF protection +csrf = CSRFProtect(app) + + +@app.errorhandler(CSRFError) +def handle_csrf_error(e): + """Handle CSRF validation errors""" + return jsonify({'error': 'CSRF token missing or invalid. Please refresh the page.'}), 400 + +@app.after_request +def set_security_headers(response): + # CORS headers (optional, controlled by CORS_ENABLE environment variable) + if CORS_ENABLE: + response.headers['Access-Control-Allow-Origin'] = APP_URL + response.headers['Access-Control-Allow-Credentials'] = 'false' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Requested-With' + + # Content Security Policy + response.headers['Content-Security-Policy'] = ( + "default-src https:; " + "script-src https:; " + "style-src 'self' 'unsafe-inline'; " + "img-src https: data:; " + "font-src https: data:; " + "object-src 'none'; " + "base-uri 'self'; " + "frame-ancestors 'none';" + ) + + # Other security headers + response.headers['X-Content-Type-Options'] = 'nosniff' + + return response + +def validate_sql_authorization(current_user): + """ + Validate if the current user is authorized to execute SQL queries. + + IMPORTANT: This is a basic example implementation. + App developers should implement their own authorization logic based on: + - Enterprise identity management integration + - Role-based access control (RBAC) + - Dynamic permission checking + - Unity Catalog permissions + + Args: + current_user (str): Username of the current user from X-Forwarded-Preferred-Username header + + Raises: + PermissionError: If the current user is not in the authorized users list + """ + if current_user not in SQL_AUTHORIZED_USERS: + raise PermissionError( + f"Access denied. User '{current_user}' is not authorized to execute SQL queries. " + f"Authorized users: {', '.join(SQL_AUTHORIZED_USERS)}" + ) + +def execute_sql_query(column_name, table_name): + """ + Execute SQL query using App Authorization (Service Principal) with SQL injection protection. + + Uses Databricks IDENTIFIER clause for safe parameterization of table and column names. + The IDENTIFIER clause interprets string parameters as SQL identifiers (table/column names) + in a SQL injection-safe manner. + + Databricks Apps automatically injects service principal credentials via: + - DATABRICKS_CLIENT_ID + - DATABRICKS_CLIENT_SECRET + + Reference: + - Auth: https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth + - IDENTIFIER: https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-names-identifier-clause + """ + conn = sql.connect( + server_hostname=cfg.host, + http_path=f"/sql/1.0/warehouses/{DATABRICKS_WAREHOUSE_ID}", + credentials_provider=lambda: cfg.authenticate + ) + + with conn.cursor() as cursor: + # Use IDENTIFIER clause for SQL injection-safe parameterization of identifiers + # Parameters are passed as a dictionary with named parameters + query = "SELECT IDENTIFIER(:column_name) FROM IDENTIFIER(:table_name) LIMIT 10" + parameters = {"column_name": column_name, "table_name": table_name} + + cursor.execute(query, parameters) + df = cursor.fetchall_arrow().to_pandas() + + if len(df) > 0: + return { + 'columns': [escape(str(col)) for col in df.columns.tolist()], + 'rows': [[escape(str(cell)) for cell in row] for row in df.values.tolist()], + 'row_count': len(df), + 'has_data': True, + 'dataframe': df + } + else: + return { + 'columns': [escape(str(col)) for col in df.columns.tolist()] if len(df.columns) > 0 else [], + 'rows': [], + 'row_count': 0, + 'has_data': False, + 'dataframe': df + } + +@app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) +def index(): + headers = request.headers + user = escape(headers.get('X-Forwarded-Preferred-Username', 'Unknown User')) + + result_data = None + parsed_data = None + error_message = None + query_info = None + + # Handle different HTTP methods + if request.method == 'POST': + # Get user input (strip whitespace but don't HTML-escape yet, IDENTIFIER clause handles SQL injection) + column_name = request.form.get('column_name', '').strip() + table_name = request.form.get('table_name', '').strip() + + if not column_name or not table_name: + error_message = "Both column name and table name are required." + else: + try: + # Validate that the current user is authorized to execute SQL queries + validate_sql_authorization(str(user)) + + # Execute query with IDENTIFIER clause for SQL injection protection + parsed_data = execute_sql_query(column_name, table_name) + + # Display query for user reference (HTML-escaped for XSS protection) + query_display = f"SELECT {escape(column_name)} FROM {escape(table_name)} LIMIT 10" + + query_info = { + 'query': query_display, + 'status': 'executed', + 'result_count': parsed_data['row_count'] if parsed_data else 0, + 'has_data': parsed_data['has_data'] if parsed_data else False + } + except PermissionError as e: + error_message = str(e) + except Exception as e: + error_message = f"Query execution failed: {escape(str(e))}" + + elif request.method in ['PUT', 'DELETE', 'PATCH']: + # Handle other state-changing methods + error_message = f"{request.method} method not implemented for this endpoint." + + # For all methods, return the template with CSRF token + return render_template('index.html', + user=user, + result_data=result_data, + parsed_data=parsed_data, + error_message=error_message, + query_info=query_info) + +if __name__ == '__main__': + app.run(debug=True, host="0.0.0.0", port=SERVER_PORT) \ No newline at end of file diff --git a/flask-secure-app-sp/app.yaml b/flask-secure-app-sp/app.yaml new file mode 100644 index 00000000..f5ee1eed --- /dev/null +++ b/flask-secure-app-sp/app.yaml @@ -0,0 +1,8 @@ +display_name: "Secure_Flask_App" +env: + - name: "SERVER_PORT" + value: "8000" + - name: "DATABRICKS_WAREHOUSE_ID" + valueFrom: "sql-warehouse" + - name: "SQL_AUTHORIZED_USERS" + value: "sql_user1@email.com,sql_user2@email.com" \ No newline at end of file diff --git a/flask-secure-app-sp/requirements.txt b/flask-secure-app-sp/requirements.txt new file mode 100644 index 00000000..9a664ebc --- /dev/null +++ b/flask-secure-app-sp/requirements.txt @@ -0,0 +1,4 @@ +Flask-WTF==1.2.2 +Flask-CORS==6.0.1 +MarkupSafe==3.0.3 +pandas==2.3.3 diff --git a/flask-secure-app-sp/static/css/style.css b/flask-secure-app-sp/static/css/style.css new file mode 100644 index 00000000..874f0dbc --- /dev/null +++ b/flask-secure-app-sp/static/css/style.css @@ -0,0 +1,294 @@ +/* Secure Flask App Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; +} + +.navbar { + background: #2c3e50; + padding: 1rem 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-brand a { + color: white; + text-decoration: none; + font-size: 1.5rem; + font-weight: bold; + margin-left: 2rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.hero { + text-align: center; + padding: 3rem 0; + background: #ecf0f1; + margin-bottom: 2rem; +} + +.user-info { + margin-top: 1rem; + padding: 1rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.user-info h2 { + color: #2c3e50; + margin-bottom: 0.5rem; +} + +.token-display { + font-family: monospace; + background: #f8f9fa; + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid #ddd; +} + +.token-missing { + font-family: monospace; + background: #f8d7da; + color: #721c24; + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid #f5c6cb; +} + +.query-section { + margin-bottom: 3rem; + padding: 2rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.query-preview { + background: #f8f9fa; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #3498db; + margin-bottom: 1.5rem; + font-family: monospace; + font-size: 1.1rem; +} + +.query-preview code { + background: #e9ecef; + padding: 0.2rem 0.4rem; + border-radius: 3px; + color: #495057; +} + +.query-info { + background: #d4edda; + color: #155724; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #28a745; + margin-bottom: 1.5rem; +} + +.query-info h3 { + margin-bottom: 0.5rem; + color: #155724; +} + +.query-info p { + margin-bottom: 0.5rem; +} + +.query-info code { + background: rgba(0, 0, 0, 0.1); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: monospace; +} + +.status-success { + color: #28a745; + font-weight: bold; +} + +.no-data-message { + background: #fff3cd; + color: #856404; + padding: 1rem; + border-radius: 4px; + border-left: 4px solid #ffc107; + margin-top: 1rem; +} + +.no-data-message p { + margin-bottom: 0.5rem; +} + +.no-data-message ul { + margin-left: 1.5rem; +} + +.no-data-message li { + margin-bottom: 0.25rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; +} + +.form-group input, .form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-family: inherit; +} + +.form-group textarea { + resize: vertical; + min-height: 120px; +} + +.btn-primary { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + background: #3498db; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #2980b9; +} + +.btn-primary:disabled { + background: #95a5a6; + cursor: not-allowed; + opacity: 0.6; +} + +.results-section { + margin-top: 2rem; + padding: 2rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.results-container { + overflow-x: auto; +} + +.results-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} + +.results-table th, .results-table td { + padding: 0.75rem; + border: 1px solid #ddd; + text-align: left; +} + +.results-table th { + background: #f8f9fa; + font-weight: bold; +} + +.results-table tr:nth-child(even) { + background: #f9f9f9; +} + +.results-table tr:hover { + background: #f0f0f0; +} + +.error-message { + background: #e74c3c; + color: white; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +.error-message h3 { + margin-bottom: 0.5rem; + color: white; +} + +.debug-info { + background: rgba(255, 255, 255, 0.1); + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; + border-left: 4px solid #f39c12; +} + +.debug-info h4 { + margin-bottom: 0.5rem; + color: #f39c12; +} + +.debug-info p { + margin-bottom: 0.5rem; +} + +.debug-info code { + background: rgba(0, 0, 0, 0.2); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: monospace; +} + +.debug-info details { + margin-top: 1rem; +} + +.debug-info summary { + cursor: pointer; + font-weight: bold; + color: #f39c12; + margin-bottom: 0.5rem; +} + +.traceback { + background: rgba(0, 0, 0, 0.2); + padding: 1rem; + border-radius: 4px; + font-family: monospace; + font-size: 0.9rem; + white-space: pre-wrap; + overflow-x: auto; + max-height: 300px; + overflow-y: auto; +} + +footer { + background: #2c3e50; + color: white; + text-align: center; + padding: 2rem 0; +} \ No newline at end of file diff --git a/flask-secure-app-sp/static/js/app.js b/flask-secure-app-sp/static/js/app.js new file mode 100644 index 00000000..94dfdea2 --- /dev/null +++ b/flask-secure-app-sp/static/js/app.js @@ -0,0 +1,97 @@ +// Function to get current CSRF token +function getCurrentCsrfToken() { + const metaTag = document.querySelector('meta[name="csrf-token"]'); + return metaTag ? metaTag.getAttribute('content') : ''; +} + +// Function to handle form submission +function handleFormSubmit(e) { + e.preventDefault(); + + const currentToken = getCurrentCsrfToken(); + const formData = new FormData(this); + const data = new URLSearchParams(); + + for (let [key, value] of formData.entries()) { + data.append(key, value); + } + + fetch(window.location.href, { + method: 'POST', + headers: { + 'X-CSRFToken': currentToken, + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: data.toString() + }) + .then(response => { + if (response.ok) { + return response.text(); + } else { + // Check if response is JSON or HTML + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json().then(err => { + throw new Error(err.error || 'Request failed'); + }); + } else { + // Server returned HTML error page (like 500 error) + return response.text().then(html => { + throw new Error('Wrong Query/Do not have access to table'); + }); + } + } + }) + .then(html => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const newMain = tempDiv.querySelector('main'); + const currentMain = document.querySelector('main'); + + if (newMain && currentMain) { + currentMain.innerHTML = newMain.innerHTML; + } + + // Update CSRF token if changed + const newCsrfToken = tempDiv.querySelector('meta[name="csrf-token"]'); + if (newCsrfToken) { + const newTokenValue = newCsrfToken.getAttribute('content'); + const currentCsrfToken = document.querySelector('meta[name="csrf-token"]'); + if (currentCsrfToken) { + currentCsrfToken.setAttribute('content', newTokenValue); + } + } + + attachEventListeners(); + }) + .catch(error => { + alert('Error: ' + error.message); + }); +} + +// Function to attach event listeners +function attachEventListeners() { + const form = document.getElementById('query-form'); + if (form) { + form.addEventListener('submit', handleFormSubmit); + } + + const columnInput = document.getElementById('column_name'); + if (columnInput) { + columnInput.addEventListener('input', function() { + document.getElementById('column-preview').textContent = this.value || 'column'; + }); + } + + const tableInput = document.getElementById('table_name'); + if (tableInput) { + tableInput.addEventListener('input', function() { + document.getElementById('table-preview').textContent = this.value || 'table'; + }); + } +} + +// Initial setup +document.addEventListener('DOMContentLoaded', attachEventListeners); \ No newline at end of file diff --git a/flask-secure-app-sp/templates/index.html b/flask-secure-app-sp/templates/index.html new file mode 100644 index 00000000..81a1fdac --- /dev/null +++ b/flask-secure-app-sp/templates/index.html @@ -0,0 +1,116 @@ + + + + + + Secure Databricks Query Tool + + + + +
+ +
+ +
+
+

Secure Databricks Query Tool

+ +
+ +
+

Execute SQL Query

+

Query: SELECT column FROM table

+ + {% if error_message %} +
+

Error

+

{{ error_message }}

+
+ {% endif %} + + {% if query_info %} +
+

Query Execution Results

+

Query: {{ query_info.query }}

+

Status: {{ query_info.status }}

+

Rows Returned: {{ query_info.result_count }}

+ + {% if not query_info.has_data %} +
+

No data found. This could mean:

+
    +
  • The table exists but has no data
  • +
  • The column name doesn't exist in the table
  • +
  • The table name is incorrect
  • +
  • You don't have permission to access this data
  • +
  • The Application doesn't have sufficient privileges
  • +
+
+ {% endif %} +
+ {% endif %} + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + {% if parsed_data and parsed_data.has_data %} +
+

Query Results ({{ parsed_data.row_count }} rows)

+
+ + + + {% for column in parsed_data.columns %} + + {% endfor %} + + + + {% for row in parsed_data.rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ column }}
{{ cell }}
+
+
+ {% endif %} +
+ + + + + + \ No newline at end of file