Implement proper CORS configuration for secure cross-origin usage

- Add flask-cors dependency and configure CORS with security best practices
- Support configurable CORS origins via environment variables
- Separate admin endpoint CORS configuration for enhanced security
- Create comprehensive CORS configuration documentation
- Add apiClient utility for CORS-aware frontend requests
- Include CORS test page for validation
- Update README with CORS configuration instructions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Adolfo Delorenzo 2025-06-02 23:51:27 -06:00
parent dc3e67e17b
commit b08574efe5
8 changed files with 1589 additions and 253 deletions

152
CORS_CONFIG.md Normal file
View File

@ -0,0 +1,152 @@
# CORS Configuration Guide
This document explains how to configure Cross-Origin Resource Sharing (CORS) for the Talk2Me application.
## Overview
CORS is configured using Flask-CORS to enable secure cross-origin usage of the API endpoints. This allows the Talk2Me application to be embedded in other websites or accessed from different domains while maintaining security.
## Environment Variables
### `CORS_ORIGINS`
Controls which domains are allowed to access the API endpoints.
- **Default**: `*` (allows all origins - use only for development)
- **Production Example**: `https://yourdomain.com,https://app.yourdomain.com`
- **Format**: Comma-separated list of allowed origins
```bash
# Development (allows all origins)
export CORS_ORIGINS="*"
# Production (restrict to specific domains)
export CORS_ORIGINS="https://talk2me.example.com,https://app.example.com"
```
### `ADMIN_CORS_ORIGINS`
Controls which domains can access admin endpoints (more restrictive).
- **Default**: `http://localhost:*` (allows all localhost ports)
- **Production Example**: `https://admin.yourdomain.com`
- **Format**: Comma-separated list of allowed admin origins
```bash
# Development
export ADMIN_CORS_ORIGINS="http://localhost:*"
# Production
export ADMIN_CORS_ORIGINS="https://admin.talk2me.example.com"
```
## Configuration Details
The CORS configuration includes:
- **Allowed Methods**: GET, POST, OPTIONS
- **Allowed Headers**: Content-Type, Authorization, X-Requested-With, X-Admin-Token
- **Exposed Headers**: Content-Range, X-Content-Range
- **Credentials Support**: Enabled (supports cookies and authorization headers)
- **Max Age**: 3600 seconds (preflight requests cached for 1 hour)
## Endpoints
All endpoints have CORS enabled with the following configuration:
### Regular API Endpoints
- `/api/*`
- `/transcribe`
- `/translate`
- `/translate/stream`
- `/speak`
- `/get_audio/*`
- `/check_tts_server`
- `/update_tts_config`
- `/health/*`
### Admin Endpoints (More Restrictive)
- `/admin/*` - Uses `ADMIN_CORS_ORIGINS` instead of general `CORS_ORIGINS`
## Security Best Practices
1. **Never use `*` in production** - Always specify exact allowed origins
2. **Use HTTPS** - Always use HTTPS URLs in production CORS origins
3. **Separate admin origins** - Keep admin endpoints on a separate, more restrictive origin list
4. **Review regularly** - Periodically review and update allowed origins
## Example Configurations
### Local Development
```bash
export CORS_ORIGINS="*"
export ADMIN_CORS_ORIGINS="http://localhost:*"
```
### Staging Environment
```bash
export CORS_ORIGINS="https://staging.talk2me.com,https://staging-app.talk2me.com"
export ADMIN_CORS_ORIGINS="https://staging-admin.talk2me.com"
```
### Production Environment
```bash
export CORS_ORIGINS="https://talk2me.com,https://app.talk2me.com"
export ADMIN_CORS_ORIGINS="https://admin.talk2me.com"
```
### Mobile App Integration
```bash
# Include mobile app schemes if needed
export CORS_ORIGINS="https://talk2me.com,https://app.talk2me.com,capacitor://localhost,ionic://localhost"
```
## Testing CORS Configuration
You can test CORS configuration using curl:
```bash
# Test preflight request
curl -X OPTIONS https://your-api.com/api/transcribe \
-H "Origin: https://allowed-origin.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
-v
# Test actual request
curl -X POST https://your-api.com/api/transcribe \
-H "Origin: https://allowed-origin.com" \
-H "Content-Type: application/json" \
-d '{"test": "data"}' \
-v
```
## Troubleshooting
### CORS Errors in Browser Console
If you see CORS errors:
1. Check that the origin is included in `CORS_ORIGINS`
2. Ensure the URL protocol matches (http vs https)
3. Check for trailing slashes in origins
4. Verify environment variables are set correctly
### Common Issues
1. **"No 'Access-Control-Allow-Origin' header"**
- Origin not in allowed list
- Check `CORS_ORIGINS` environment variable
2. **"CORS policy: The request client is not a secure context"**
- Using HTTP instead of HTTPS
- Update to use HTTPS in production
3. **"CORS policy: Credentials flag is true, but Access-Control-Allow-Credentials is not 'true'"**
- This should not occur with current configuration
- Check that `supports_credentials` is True in CORS config
## Additional Resources
- [MDN CORS Documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
- [Flask-CORS Documentation](https://flask-cors.readthedocs.io/)

View File

@ -64,6 +64,20 @@ A mobile-friendly web application that translates spoken language between multip
- Ollama provides access to the Gemma 3 model for translation
- OpenAI Edge TTS delivers natural-sounding speech output
## CORS Configuration
The application supports Cross-Origin Resource Sharing (CORS) for secure cross-origin usage. See [CORS_CONFIG.md](CORS_CONFIG.md) for detailed configuration instructions.
Quick setup:
```bash
# Development (allow all origins)
export CORS_ORIGINS="*"
# Production (restrict to specific domains)
export CORS_ORIGINS="https://yourdomain.com,https://app.yourdomain.com"
export ADMIN_CORS_ORIGINS="https://admin.yourdomain.com"
```
## Mobile Support
The interface is fully responsive and designed to work well on mobile devices.

28
app.py
View File

@ -5,6 +5,7 @@ import requests
import json
import logging
from flask import Flask, render_template, request, jsonify, Response, send_file, send_from_directory, stream_with_context
from flask_cors import CORS, cross_origin
import whisper
import torch
import ollama
@ -48,6 +49,33 @@ def with_error_boundary(func):
app = Flask(__name__)
# Configure CORS with security best practices
cors_config = {
"origins": os.environ.get('CORS_ORIGINS', '*').split(','), # Default to * for development, restrict in production
"methods": ["GET", "POST", "OPTIONS"],
"allow_headers": ["Content-Type", "Authorization", "X-Requested-With", "X-Admin-Token"],
"expose_headers": ["Content-Range", "X-Content-Range"],
"supports_credentials": True,
"max_age": 3600 # Cache preflight requests for 1 hour
}
# Apply CORS configuration
CORS(app, resources={
r"/api/*": cors_config,
r"/transcribe": cors_config,
r"/translate": cors_config,
r"/translate/stream": cors_config,
r"/speak": cors_config,
r"/get_audio/*": cors_config,
r"/check_tts_server": cors_config,
r"/update_tts_config": cors_config,
r"/health/*": cors_config,
r"/admin/*": {
**cors_config,
"origins": os.environ.get('ADMIN_CORS_ORIGINS', 'http://localhost:*').split(',')
}
})
# Configure upload folder - use environment variable or default to secure temp directory
default_upload_folder = os.path.join(tempfile.gettempdir(), 'talk2me_uploads')
upload_folder = os.environ.get('UPLOAD_FOLDER', default_upload_folder)

View File

@ -1,4 +1,5 @@
flask
flask-cors
requests
openai-whisper
torch

File diff suppressed because it is too large Load Diff

155
static/js/src/apiClient.ts Normal file
View File

@ -0,0 +1,155 @@
// API Client with CORS support
export interface ApiClientConfig {
baseUrl?: string;
credentials?: RequestCredentials;
headers?: HeadersInit;
}
export class ApiClient {
private static instance: ApiClient;
private config: ApiClientConfig;
private constructor() {
// Default configuration
this.config = {
baseUrl: '', // Use same origin by default
credentials: 'same-origin', // Change to 'include' for cross-origin requests
headers: {
'X-Requested-With': 'XMLHttpRequest' // Identify as AJAX request
}
};
// Check if we're in a cross-origin context
this.detectCrossOrigin();
}
static getInstance(): ApiClient {
if (!ApiClient.instance) {
ApiClient.instance = new ApiClient();
}
return ApiClient.instance;
}
// Detect if we're making cross-origin requests
private detectCrossOrigin(): void {
// Check if the app is loaded from a different origin
const currentScript = document.currentScript as HTMLScriptElement | null;
const scriptSrc = currentScript?.src || '';
if (scriptSrc && !scriptSrc.startsWith(window.location.origin)) {
// We're likely in a cross-origin context
this.config.credentials = 'include';
console.log('Cross-origin context detected, enabling credentials');
}
// Also check for explicit configuration in meta tags
const corsOrigin = document.querySelector('meta[name="cors-origin"]');
if (corsOrigin) {
const origin = corsOrigin.getAttribute('content');
if (origin && origin !== window.location.origin) {
this.config.baseUrl = origin;
this.config.credentials = 'include';
console.log(`Using CORS origin: ${origin}`);
}
}
}
// Configure the API client
configure(config: Partial<ApiClientConfig>): void {
this.config = { ...this.config, ...config };
}
// Make a fetch request with CORS support
async fetch(url: string, options: RequestInit = {}): Promise<Response> {
// Construct full URL
const fullUrl = this.config.baseUrl ? `${this.config.baseUrl}${url}` : url;
// Merge headers
const headers = new Headers(options.headers);
if (this.config.headers) {
const configHeaders = new Headers(this.config.headers);
configHeaders.forEach((value, key) => {
if (!headers.has(key)) {
headers.set(key, value);
}
});
}
// Merge options with defaults
const fetchOptions: RequestInit = {
...options,
headers,
credentials: options.credentials || this.config.credentials
};
// Add CORS mode if cross-origin
if (this.config.baseUrl && this.config.baseUrl !== window.location.origin) {
fetchOptions.mode = 'cors';
}
try {
const response = await fetch(fullUrl, fetchOptions);
// Check for CORS errors
if (!response.ok && response.type === 'opaque') {
throw new Error('CORS request failed - check server CORS configuration');
}
return response;
} catch (error) {
// Enhanced error handling for CORS issues
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
console.error('CORS Error: Failed to fetch. Check that:', {
requestedUrl: fullUrl,
origin: window.location.origin,
credentials: fetchOptions.credentials,
mode: fetchOptions.mode
});
throw new Error('CORS request failed. The server may not allow requests from this origin.');
}
throw error;
}
}
// Convenience methods
async get(url: string, options?: RequestInit): Promise<Response> {
return this.fetch(url, { ...options, method: 'GET' });
}
async post(url: string, body?: any, options?: RequestInit): Promise<Response> {
const init: RequestInit = { ...options, method: 'POST' };
if (body) {
if (body instanceof FormData) {
init.body = body;
} else {
init.headers = {
...init.headers,
'Content-Type': 'application/json'
};
init.body = JSON.stringify(body);
}
}
return this.fetch(url, init);
}
// JSON convenience methods
async getJSON<T>(url: string, options?: RequestInit): Promise<T> {
const response = await this.get(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
async postJSON<T>(url: string, body?: any, options?: RequestInit): Promise<T> {
const response = await this.post(url, body, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
}
// Export a singleton instance
export const apiClient = ApiClient.getInstance();

View File

@ -21,10 +21,15 @@ import { Validator } from './validator';
import { StreamingTranslation } from './streamingTranslation';
import { PerformanceMonitor } from './performanceMonitor';
import { SpeakerManager } from './speakerManager';
// import { apiClient } from './apiClient'; // Available for cross-origin requests
// Initialize error boundary
const errorBoundary = ErrorBoundary.getInstance();
// Configure API client if needed for cross-origin requests
// import { apiClient } from './apiClient';
// apiClient.configure({ baseUrl: 'https://api.talk2me.com', credentials: 'include' });
document.addEventListener('DOMContentLoaded', function() {
// Set up global error handler
errorBoundary.setGlobalErrorHandler((error, errorInfo) => {

228
test-cors.html Normal file
View File

@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CORS Test for Talk2Me</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
}
.test-result {
margin: 10px 0;
padding: 10px;
border-radius: 5px;
}
.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
button {
background-color: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
background-color: #0056b3;
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
}
#results {
margin-top: 20px;
}
pre {
background-color: #f8f9fa;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
}
</style>
</head>
<body>
<h1>CORS Test for Talk2Me API</h1>
<p>This page tests CORS configuration for the Talk2Me API. Open this file from a different origin (e.g., file:// or a different port) to test cross-origin requests.</p>
<div>
<label for="apiUrl">API Base URL:</label>
<input type="text" id="apiUrl" placeholder="http://localhost:5005" value="http://localhost:5005">
</div>
<h2>Tests:</h2>
<button onclick="testHealthEndpoint()">Test Health Endpoint</button>
<button onclick="testPreflightRequest()">Test Preflight Request</button>
<button onclick="testTranscribeEndpoint()">Test Transcribe Endpoint (OPTIONS)</button>
<button onclick="testWithCredentials()">Test With Credentials</button>
<div id="results"></div>
<script>
function addResult(test, success, message, details = null) {
const resultsDiv = document.getElementById('results');
const resultDiv = document.createElement('div');
resultDiv.className = `test-result ${success ? 'success' : 'error'}`;
let html = `<strong>${test}:</strong> ${message}`;
if (details) {
html += `<pre>${JSON.stringify(details, null, 2)}</pre>`;
}
resultDiv.innerHTML = html;
resultsDiv.appendChild(resultDiv);
}
function getApiUrl() {
return document.getElementById('apiUrl').value.trim();
}
async function testHealthEndpoint() {
const apiUrl = getApiUrl();
try {
const response = await fetch(`${apiUrl}/health`, {
method: 'GET',
mode: 'cors',
headers: {
'Origin': window.location.origin
}
});
const data = await response.json();
// Check CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': response.headers.get('Access-Control-Allow-Origin'),
'Access-Control-Allow-Credentials': response.headers.get('Access-Control-Allow-Credentials')
};
addResult('Health Endpoint GET', true, 'Request successful', {
status: response.status,
data: data,
corsHeaders: corsHeaders
});
} catch (error) {
addResult('Health Endpoint GET', false, error.message);
}
}
async function testPreflightRequest() {
const apiUrl = getApiUrl();
try {
const response = await fetch(`${apiUrl}/api/push-public-key`, {
method: 'OPTIONS',
mode: 'cors',
headers: {
'Origin': window.location.origin,
'Access-Control-Request-Method': 'GET',
'Access-Control-Request-Headers': 'content-type'
}
});
const corsHeaders = {
'Access-Control-Allow-Origin': response.headers.get('Access-Control-Allow-Origin'),
'Access-Control-Allow-Methods': response.headers.get('Access-Control-Allow-Methods'),
'Access-Control-Allow-Headers': response.headers.get('Access-Control-Allow-Headers'),
'Access-Control-Max-Age': response.headers.get('Access-Control-Max-Age')
};
addResult('Preflight Request', response.ok, `Status: ${response.status}`, corsHeaders);
} catch (error) {
addResult('Preflight Request', false, error.message);
}
}
async function testTranscribeEndpoint() {
const apiUrl = getApiUrl();
try {
const response = await fetch(`${apiUrl}/transcribe`, {
method: 'OPTIONS',
mode: 'cors',
headers: {
'Origin': window.location.origin,
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type'
}
});
const corsHeaders = {
'Access-Control-Allow-Origin': response.headers.get('Access-Control-Allow-Origin'),
'Access-Control-Allow-Methods': response.headers.get('Access-Control-Allow-Methods'),
'Access-Control-Allow-Headers': response.headers.get('Access-Control-Allow-Headers'),
'Access-Control-Allow-Credentials': response.headers.get('Access-Control-Allow-Credentials')
};
addResult('Transcribe Endpoint OPTIONS', response.ok, `Status: ${response.status}`, corsHeaders);
} catch (error) {
addResult('Transcribe Endpoint OPTIONS', false, error.message);
}
}
async function testWithCredentials() {
const apiUrl = getApiUrl();
try {
const response = await fetch(`${apiUrl}/health`, {
method: 'GET',
mode: 'cors',
credentials: 'include',
headers: {
'Origin': window.location.origin
}
});
const data = await response.json();
addResult('Request with Credentials', true, 'Request successful', {
status: response.status,
credentialsIncluded: true,
data: data
});
} catch (error) {
addResult('Request with Credentials', false, error.message);
}
}
// Clear results before running new tests
function clearResults() {
document.getElementById('results').innerHTML = '';
}
// Add event listeners
document.querySelectorAll('button').forEach(button => {
button.addEventListener('click', (e) => {
if (!e.target.textContent.includes('Test')) return;
clearResults();
});
});
// Show current origin
window.addEventListener('load', () => {
const info = document.createElement('div');
info.style.marginBottom = '20px';
info.style.padding = '10px';
info.style.backgroundColor = '#e9ecef';
info.style.borderRadius = '5px';
info.innerHTML = `<strong>Current Origin:</strong> ${window.location.origin}<br>
<strong>Protocol:</strong> ${window.location.protocol}<br>
<strong>Note:</strong> For effective CORS testing, open this file from a different origin than your API server.`;
document.body.insertBefore(info, document.querySelector('h2'));
});
</script>
</body>
</html>