diff --git a/README.md b/README.md index 921d13b..f88eb1a 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,7 @@ The service supports multi-region deployment to AWS Lambda across multiple geogr - `AWS_ACCESS_KEY_ID` - AWS access key for deployment - `AWS_SECRET_ACCESS_KEY` - AWS secret key for deployment - `CF_SECRET_KEY` - CloudFlare Turnstile secret key (optional, for bot protection) - - `CORS_ORIGIN` - CORS origin URL (optional, defaults to `https://secrets.merabytes.com`) + - `CORS_ORIGIN` - Comma-separated list of allowed CORS origins (optional, defaults to secrets.merabytes.com, app.merabytes.com, and local.merabytes.com) ### Automated Deployment @@ -587,31 +587,51 @@ aws lambda update-function-configuration \ ## CORS Configuration -The Lambda function supports Cross-Origin Resource Sharing (CORS) to allow web applications to interact with the secrets API. The CORS origin URL can be configured through the `CORS_ORIGIN` environment variable. +The Lambda function supports Cross-Origin Resource Sharing (CORS) to allow web applications to interact with the secrets API. The service now supports **multiple allowed origins** with dynamic origin matching. ### Configuration -- **Default**: If not specified, defaults to `https://secrets.merabytes.com` -- **Custom**: Set `CORS_ORIGIN` environment variable to your frontend URL +- **Default Origins**: If not specified, the service allows requests from: + - `https://secrets.merabytes.com` + - `https://app.merabytes.com` + - `https://local.merabytes.com` -**Example:** +- **Custom Origins**: Set `CORS_ORIGIN` environment variable to a comma-separated list of allowed origins +- **Dynamic Matching**: The service inspects the `Origin` header in each request and returns it in the response if it's in the allowed list + +**Example - Single origin:** ```bash -# Set custom CORS origin +# Set custom CORS origin (single domain) aws lambda update-function-configuration \ --function-name AcidoSecrets \ --environment "Variables={...,CORS_ORIGIN=https://myapp.example.com}" ``` +**Example - Multiple origins:** +```bash +# Set custom CORS origins (multiple domains, comma-separated) +aws lambda update-function-configuration \ + --function-name AcidoSecrets \ + --environment "Variables={...,CORS_ORIGIN=https://app1.example.com,https://app2.example.com,https://local.example.com}" +``` + **In GitHub Actions:** -Add `CORS_ORIGIN` to your repository secrets to configure it during deployment. +Add `CORS_ORIGIN` to your repository secrets to configure it during deployment. Use a comma-separated list for multiple origins. ### CORS Headers The service responds with the following CORS headers: -- `Access-Control-Allow-Origin`: Configurable via `CORS_ORIGIN` (default: `https://secrets.merabytes.com`) +- `Access-Control-Allow-Origin`: Dynamically set to match the request origin if it's in the allowed list (defaults to first allowed origin if no match) - `Access-Control-Allow-Methods`: `POST, OPTIONS` - `Access-Control-Allow-Headers`: `Content-Type` +### How It Works + +1. The service extracts the `Origin` header from incoming requests +2. If the origin is in the allowed list, it's returned in the `Access-Control-Allow-Origin` response header +3. If the origin is not in the allowed list (or missing), the first allowed origin is used as the default +4. This allows multiple frontend applications to interact with the same Lambda function while maintaining security + ## CloudFlare Turnstile Integration CloudFlare Turnstile provides bot protection to prevent abuse of the secrets service. It's **optional** and only activated when the `CF_SECRET_KEY` environment variable is set. diff --git a/lambda_handler.py b/lambda_handler.py index 920da7f..7097cc1 100644 --- a/lambda_handler.py +++ b/lambda_handler.py @@ -59,15 +59,84 @@ def _decrypt_with_secret_key(data: str) -> str: return decrypt_secret(data, secret_key) -# CORS headers - origin is configurable via CORS_ORIGIN environment variable +# CORS configuration +# Default allowed origins - supports multiple origins +DEFAULT_ALLOWED_ORIGINS = [ + "https://secrets.merabytes.com", + "https://app.merabytes.com", + "https://local.merabytes.com" +] + +# Allow custom origins via CORS_ORIGIN environment variable (comma-separated) +CORS_ORIGIN_ENV = os.environ.get("CORS_ORIGIN", "") +if CORS_ORIGIN_ENV: + # Parse comma-separated origins and filter out empty strings + ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ORIGIN_ENV.split(",") if origin.strip()] + # If parsing resulted in an empty list, fall back to defaults + if not ALLOWED_ORIGINS: + ALLOWED_ORIGINS = DEFAULT_ALLOWED_ORIGINS +else: + ALLOWED_ORIGINS = DEFAULT_ALLOWED_ORIGINS + +# Initialize CORS_HEADERS with default (will be updated per request) CORS_HEADERS = { - "Access-Control-Allow-Origin": os.environ.get("CORS_ORIGIN", "https://secrets.merabytes.com"), + "Access-Control-Allow-Origin": ALLOWED_ORIGINS[0], "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", "Content-Type": "application/json" } +def _get_cors_headers(origin=None): + """ + Get CORS headers with appropriate origin. + + If origin is provided and is in the allowed list, use it. + Otherwise, use the first allowed origin as default. + + Args: + origin: Origin header from the request + + Returns: + dict: CORS headers + """ + allowed_origin = ALLOWED_ORIGINS[0] # Default to first allowed origin + + if origin and origin in ALLOWED_ORIGINS: + allowed_origin = origin + + return { + "Access-Control-Allow-Origin": allowed_origin, + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Content-Type": "application/json" + } + + +def _extract_origin(event): + """ + Extract the Origin header from the Lambda event. + Uses case-insensitive header lookup. + + Args: + event: Lambda event object + + Returns: + str: Origin header value or None + """ + # Try to get headers from the event + headers = event.get('headers', {}) + if not headers: + return None + + # HTTP header names are case-insensitive - search case-insensitively + for key, value in headers.items(): + if key.lower() == 'origin': + return value + + return None + + def _get_version(): """Read version from VERSION file.""" try: @@ -542,7 +611,8 @@ def lambda_handler(event, context): - SECRET_KEY: Additional encryption key for enhanced security (auto-generated during deployment) Environment variables optional: - - CORS_ORIGIN: CORS origin URL (default: https://secrets.merabytes.com) + - CORS_ORIGIN: Comma-separated list of allowed CORS origins + (default: https://secrets.merabytes.com,https://app.merabytes.com,https://local.merabytes.com) Multi-layer encryption: - All secrets are encrypted with SECRET_KEY (system-level encryption, always applied) @@ -564,7 +634,16 @@ def lambda_handler(event, context): Returns: dict: Response with statusCode and body containing operation result """ + # Note: Lambda execution contexts are single-threaded and handle one request at a time, + # so modifying the global CORS_HEADERS here is safe and doesn't cause race conditions. + global CORS_HEADERS + original_event = event + + # Extract origin from request and set CORS headers dynamically + origin = _extract_origin(original_event) + CORS_HEADERS = _get_cors_headers(origin) + event = parse_lambda_event(event) # Handle OPTIONS preflight