Fix security headers for API routes to ensure compatibility with CURL interface
ci/woodpecker/push/woodpecker Pipeline was successful Details

This commit is contained in:
colin 2025-07-03 18:08:09 -04:00
parent 26a36de4de
commit 35f80738a7
3 changed files with 75 additions and 30 deletions

View File

@ -7,9 +7,9 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: ploughshares:dev image: ploughshares:dev
ports: ports:
- "5001:5001" - "5005:5000"
environment: environment:
- FLASK_RUN_PORT=5001 - FLASK_RUN_PORT=5000
- FLASK_ENV=development - FLASK_ENV=development
- FLASK_DEBUG=1 - FLASK_DEBUG=1
- POSTGRES_HOST=db - POSTGRES_HOST=db
@ -18,6 +18,10 @@ services:
- POSTGRES_USER=ploughshares - POSTGRES_USER=ploughshares
- POSTGRES_PASSWORD=ploughshares_password - POSTGRES_PASSWORD=ploughshares_password
- APP_ENV=development - 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@ -25,7 +25,7 @@ COPY static/ ./static/
# Create uploads directory # Create uploads directory
RUN mkdir -p uploads 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..." && \ RUN echo "Calculating CSP hashes for static resources..." && \
# Calculate hash for JS files # Calculate hash for JS files
JS_HASH=$(openssl dgst -sha256 -binary static/js/bootstrap.bundle.min.js | openssl base64) && \ 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" && \ echo "CSS hash: $CSS_HASH" && \
CUSTOM_CSS_HASH=$(openssl dgst -sha256 -binary static/css/custom.css | openssl base64) && \ CUSTOM_CSS_HASH=$(openssl dgst -sha256 -binary static/css/custom.css | openssl base64) && \
echo "Custom CSS hash: $CUSTOM_CSS_HASH" && \ echo "Custom CSS hash: $CUSTOM_CSS_HASH" && \
# Create environment variables file for CSP hashes # Export CSP hashes as environment variables directly
echo "export CSP_JS_HASH=$JS_HASH" > /app/csp_hashes.env && \ 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_CSS_HASH=\"$CSS_HASH\"" >> /app/csp_hashes.env && \
echo "export CSP_CUSTOM_CSS_HASH=$CUSTOM_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 # Set build argument for APP_VERSION
ARG APP_VERSION=unknown ARG APP_VERSION=unknown
ENV APP_VERSION=$APP_VERSION ENV APP_VERSION=$APP_VERSION
# Expose the port the app runs on # Expose the port the app runs on
EXPOSE 5001 EXPOSE 5000
# Command to run the application with CSP hashes # Command to run the application with CSP hashes
CMD ["/bin/bash", "-c", "source /app/csp_hashes.env && exec python app.py"] CMD ["/bin/bash", "-c", "source /app/csp_hashes.env && exec python app.py"]

View File

@ -7,6 +7,7 @@ from datetime import datetime
import locale import locale
import logging import logging
from flask_talisman import Talisman from flask_talisman import Talisman
import re
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -53,10 +54,9 @@ if CSP_CSS_HASH:
if CSP_CUSTOM_CSS_HASH: if CSP_CUSTOM_CSS_HASH:
logger.info(f"Using pre-calculated CSP hash for custom CSS: sha256-{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 # Define the base CSP for UI routes
# Base CSP settings with pre-calculated hashes for static resources ui_csp = {
csp = { 'default-src': "'none'", # Deny by default
'default-src': ["'self'"],
'script-src': ["'self'"] + ([f"'sha256-{CSP_JS_HASH}'"] if CSP_JS_HASH else []), 'script-src': ["'self'"] + ([f"'sha256-{CSP_JS_HASH}'"] if CSP_JS_HASH else []),
'style-src': ["'self'"] + 'style-src': ["'self'"] +
([f"'sha256-{CSP_CSS_HASH}'"] if CSP_CSS_HASH else []) + ([f"'sha256-{CSP_CSS_HASH}'"] if CSP_CSS_HASH else []) +
@ -64,8 +64,9 @@ csp = {
'img-src': ["'self'", "data:"], 'img-src': ["'self'", "data:"],
'font-src': ["'self'"], 'font-src': ["'self'"],
'connect-src': "'self'", 'connect-src': "'self'",
'object-src': "'none'", 'manifest-src': "'self'",
'frame-ancestors': "'none'", 'object-src': "'none'", # Explicitly disallow objects
'frame-ancestors': "'none'", # Prevent framing
'base-uri': "'self'", 'base-uri': "'self'",
'form-action': "'self'" 'form-action': "'self'"
} }
@ -74,18 +75,18 @@ csp = {
if APP_DOMAIN: if APP_DOMAIN:
logger.info(f"Configuring CSP for domain: {APP_DOMAIN}") logger.info(f"Configuring CSP for domain: {APP_DOMAIN}")
# Add domain to connect-src if needed # Add domain to connect-src if needed
if APP_DOMAIN not in csp['connect-src']: if APP_DOMAIN not in ui_csp['connect-src']:
if isinstance(csp['connect-src'], list): if isinstance(ui_csp['connect-src'], list):
csp['connect-src'].append(APP_DOMAIN) ui_csp['connect-src'].append(APP_DOMAIN)
else: 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 # Update form-action to include the domain
if isinstance(csp['form-action'], list): if isinstance(ui_csp['form-action'], list):
if APP_DOMAIN not in csp['form-action']: if APP_DOMAIN not in ui_csp['form-action']:
csp['form-action'].append(APP_DOMAIN) ui_csp['form-action'].append(APP_DOMAIN)
else: 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) # Configure Permissions-Policy (formerly Feature-Policy)
# Deny access to all browser features that we don't need # Deny access to all browser features that we don't need
@ -131,10 +132,17 @@ additional_headers = {
'Cross-Origin-Opener-Policy': 'same-origin' '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( talisman = Talisman(
app, app,
content_security_policy=csp, content_security_policy=csp_for_request,
content_security_policy_nonce_in=['script-src'], content_security_policy_nonce_in=['script-src'],
feature_policy=permissions_policy, feature_policy=permissions_policy,
force_https=force_https, force_https=force_https,
@ -149,13 +157,44 @@ talisman = Talisman(
session_cookie_http_only=True session_cookie_http_only=True
) )
# Add additional security headers that Talisman doesn't support natively # Add CORS headers for API routes
@app.after_request @app.after_request
def add_security_headers(response): 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(): for header, value in additional_headers.items():
response.headers[header] = value response.headers[header] = value
return response return response
# API route handler for OPTIONS requests (CORS preflight)
@app.route('/api/<path:path>', 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 # Set locale for currency formatting
try: try:
locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')