diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6ac9618..0d8926d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -7,9 +7,9 @@ services: dockerfile: Dockerfile image: ploughshares:dev ports: - - "5001:5001" + - "5005:5000" environment: - - FLASK_RUN_PORT=5001 + - FLASK_RUN_PORT=5000 - FLASK_ENV=development - FLASK_DEBUG=1 - POSTGRES_HOST=db @@ -18,6 +18,10 @@ services: - POSTGRES_USER=ploughshares - POSTGRES_PASSWORD=ploughshares_password - APP_ENV=development + - CSP_JS_HASH=default_js_hash + - CSP_CSS_HASH=default_css_hash + - CSP_CUSTOM_CSS_HASH=default_custom_css_hash + command: python app.py depends_on: db: condition: service_healthy diff --git a/docker/ploughshares/Dockerfile b/docker/ploughshares/Dockerfile index 8644578..65d7ad6 100644 --- a/docker/ploughshares/Dockerfile +++ b/docker/ploughshares/Dockerfile @@ -25,7 +25,7 @@ COPY static/ ./static/ # Create uploads directory RUN mkdir -p uploads -# Calculate CSP hashes for static resources +# Calculate CSP hashes for static resources and create environment variables RUN echo "Calculating CSP hashes for static resources..." && \ # Calculate hash for JS files JS_HASH=$(openssl dgst -sha256 -binary static/js/bootstrap.bundle.min.js | openssl base64) && \ @@ -35,17 +35,19 @@ RUN echo "Calculating CSP hashes for static resources..." && \ echo "CSS hash: $CSS_HASH" && \ CUSTOM_CSS_HASH=$(openssl dgst -sha256 -binary static/css/custom.css | openssl base64) && \ echo "Custom CSS hash: $CUSTOM_CSS_HASH" && \ - # Create environment variables file for CSP hashes - echo "export CSP_JS_HASH=$JS_HASH" > /app/csp_hashes.env && \ - echo "export CSP_CSS_HASH=$CSS_HASH" >> /app/csp_hashes.env && \ - echo "export CSP_CUSTOM_CSS_HASH=$CUSTOM_CSS_HASH" >> /app/csp_hashes.env + # Export CSP hashes as environment variables directly + echo "export CSP_JS_HASH=\"$JS_HASH\"" > /app/csp_hashes.env && \ + echo "export CSP_CSS_HASH=\"$CSS_HASH\"" >> /app/csp_hashes.env && \ + echo "export CSP_CUSTOM_CSS_HASH=\"$CUSTOM_CSS_HASH\"" >> /app/csp_hashes.env && \ + # Make the file executable + chmod +x /app/csp_hashes.env # Set build argument for APP_VERSION ARG APP_VERSION=unknown ENV APP_VERSION=$APP_VERSION # Expose the port the app runs on -EXPOSE 5001 +EXPOSE 5000 # Command to run the application with CSP hashes CMD ["/bin/bash", "-c", "source /app/csp_hashes.env && exec python app.py"] \ No newline at end of file diff --git a/docker/ploughshares/app.py b/docker/ploughshares/app.py index c17ef13..733cfa8 100644 --- a/docker/ploughshares/app.py +++ b/docker/ploughshares/app.py @@ -7,6 +7,7 @@ from datetime import datetime import locale import logging from flask_talisman import Talisman +import re # Configure logging logging.basicConfig( @@ -53,19 +54,19 @@ if CSP_CSS_HASH: if CSP_CUSTOM_CSS_HASH: logger.info(f"Using pre-calculated CSP hash for custom CSS: sha256-{CSP_CUSTOM_CSS_HASH}") -# Configure security headers with Talisman -# Base CSP settings with pre-calculated hashes for static resources -csp = { - 'default-src': ["'self'"], +# Define the base CSP for UI routes +ui_csp = { + 'default-src': "'none'", # Deny by default 'script-src': ["'self'"] + ([f"'sha256-{CSP_JS_HASH}'"] if CSP_JS_HASH else []), 'style-src': ["'self'"] + - ([f"'sha256-{CSP_CSS_HASH}'"] if CSP_CSS_HASH else []) + - ([f"'sha256-{CSP_CUSTOM_CSS_HASH}'"] if CSP_CUSTOM_CSS_HASH else []), + ([f"'sha256-{CSP_CSS_HASH}'"] if CSP_CSS_HASH else []) + + ([f"'sha256-{CSP_CUSTOM_CSS_HASH}'"] if CSP_CUSTOM_CSS_HASH else []), 'img-src': ["'self'", "data:"], 'font-src': ["'self'"], 'connect-src': "'self'", - 'object-src': "'none'", - 'frame-ancestors': "'none'", + 'manifest-src': "'self'", + 'object-src': "'none'", # Explicitly disallow objects + 'frame-ancestors': "'none'", # Prevent framing 'base-uri': "'self'", 'form-action': "'self'" } @@ -74,18 +75,18 @@ csp = { if APP_DOMAIN: logger.info(f"Configuring CSP for domain: {APP_DOMAIN}") # Add domain to connect-src if needed - if APP_DOMAIN not in csp['connect-src']: - if isinstance(csp['connect-src'], list): - csp['connect-src'].append(APP_DOMAIN) + if APP_DOMAIN not in ui_csp['connect-src']: + if isinstance(ui_csp['connect-src'], list): + ui_csp['connect-src'].append(APP_DOMAIN) else: - csp['connect-src'] = [csp['connect-src'], APP_DOMAIN] + ui_csp['connect-src'] = [ui_csp['connect-src'], APP_DOMAIN] # Update form-action to include the domain - if isinstance(csp['form-action'], list): - if APP_DOMAIN not in csp['form-action']: - csp['form-action'].append(APP_DOMAIN) + if isinstance(ui_csp['form-action'], list): + if APP_DOMAIN not in ui_csp['form-action']: + ui_csp['form-action'].append(APP_DOMAIN) else: - csp['form-action'] = [csp['form-action'], APP_DOMAIN] + ui_csp['form-action'] = [ui_csp['form-action'], APP_DOMAIN] # Configure Permissions-Policy (formerly Feature-Policy) # Deny access to all browser features that we don't need @@ -131,10 +132,17 @@ additional_headers = { 'Cross-Origin-Opener-Policy': 'same-origin' } -# Initialize Talisman +# Custom function to determine if CSP should be applied +def csp_for_request(): + # Disable CSP for API routes + if request.path.startswith('/api/'): + return None + return ui_csp + +# Initialize Talisman with a dynamic CSP function talisman = Talisman( app, - content_security_policy=csp, + content_security_policy=csp_for_request, content_security_policy_nonce_in=['script-src'], feature_policy=permissions_policy, force_https=force_https, @@ -149,13 +157,44 @@ talisman = Talisman( session_cookie_http_only=True ) -# Add additional security headers that Talisman doesn't support natively +# Add CORS headers for API routes @app.after_request -def add_security_headers(response): - for header, value in additional_headers.items(): - response.headers[header] = value +def add_api_headers(response): + if request.path.startswith('/api/'): + # Add CORS headers for API routes + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + response.headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + + # Ensure CSP is completely removed for API routes + if 'Content-Security-Policy' in response.headers: + del response.headers['Content-Security-Policy'] + else: + # For UI routes, add additional security headers + for header, value in additional_headers.items(): + response.headers[header] = value + return response +# API route handler for OPTIONS requests (CORS preflight) +@app.route('/api/', methods=['OPTIONS']) +def api_options(path): + response = jsonify({'status': 'ok'}) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + return response + +# Create a test API endpoint to verify the CSP settings +@app.route('/api/test', methods=['GET']) +def api_test(): + return jsonify({ + 'status': 'ok', + 'message': 'API endpoint is working correctly', + 'version': VERSION + }) + # Set locale for currency formatting try: locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')