From 693072320bda85fc6c53a814854d8b459fe840f6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 13:52:26 +0900 Subject: [PATCH 01/29] add: passphrase to model --- .../common/python/ddb/models/delibird_link.py | 16 ++++++++++++++++ modules/aws_iam/lambda_admin_portal.tf | 1 + 2 files changed, 17 insertions(+) diff --git a/lambda/layers/common/python/ddb/models/delibird_link.py b/lambda/layers/common/python/ddb/models/delibird_link.py index 80dae1a..f2b7b4c 100644 --- a/lambda/layers/common/python/ddb/models/delibird_link.py +++ b/lambda/layers/common/python/ddb/models/delibird_link.py @@ -1,3 +1,4 @@ +import hashlib import json import logging import os @@ -53,6 +54,8 @@ class DelibirdLink: expiration_date: Optional[datetime] = None expired_origin: Optional[str] = None + _passphrase: Optional[str] = None + query_omit: bool = False query_whitelist: set[str] = None @@ -109,6 +112,17 @@ def increment_uses(self) -> bool: self.uses += 1 return True + def is_protected(self) -> bool: + return self._passphrase is not None + + def validate_challenge(self, nonce: str, challenge: str) -> bool: + if not self.is_protected(): + raise ValueError("Link is not protected.") + if not nonce: + raise ValueError("Nonce is required.") + expected = hashlib.sha256(f"{self._passphrase}#{nonce}".encode()).hexdigest() + return challenge == expected + @staticmethod def from_model(model: "DelibirdLinkTableModel") -> "DelibirdLink": return DelibirdLink( @@ -123,6 +137,7 @@ def from_model(model: "DelibirdLinkTableModel") -> "DelibirdLink": tag=set(model.tag) if model.tag is not None else None, expiration_date=as_jst(model.expiration_date) if model.expiration_date is not None else None, expired_origin=model.expired_origin, + _passphrase=model.passphrase, query_omit=model.query_omit, query_whitelist=model.query_whitelist, max_uses=int(model.max_uses) if model.max_uses is not None else None @@ -147,6 +162,7 @@ class Meta: expiration_date = DateTimeAttribute(null=True) expired_origin = UnicodeAttribute(null=True) + passphrase = UnicodeAttribute(null=True) query_omit = BooleanAttribute(null=False, default=True) query_whitelist = UnicodeSetAttribute(null=True) max_uses = NumberAttribute(null=True) diff --git a/modules/aws_iam/lambda_admin_portal.tf b/modules/aws_iam/lambda_admin_portal.tf index 782e4f3..aab071b 100644 --- a/modules/aws_iam/lambda_admin_portal.tf +++ b/modules/aws_iam/lambda_admin_portal.tf @@ -54,6 +54,7 @@ resource "aws_iam_role_policy" "lambda_admin_portal" { "tag", "expiration_date", "expired_origin", + "passphrase", "query_omit", "query_whitelist" ] From 119f4626c8aa33f010d0849731d87a473b662e56 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 17:31:11 +0900 Subject: [PATCH 02/29] add: DelibirdLinkNonceTable --- environments/sample/main.tf | 5 ++ lambda/redirect_request/models/__init__.py | 0 .../redirect_request/models/delibird_nonce.py | 52 +++++++++++++++++++ modules/aws_dynamodb/output.tf | 4 ++ modules/aws_dynamodb/table.tf | 19 +++++++ modules/aws_lambda/function.tf | 1 + modules/aws_lambda/variables.tf | 7 +++ 7 files changed, 88 insertions(+) create mode 100644 lambda/redirect_request/models/__init__.py create mode 100644 lambda/redirect_request/models/delibird_nonce.py diff --git a/environments/sample/main.tf b/environments/sample/main.tf index 1adb2f3..44aac0d 100644 --- a/environments/sample/main.tf +++ b/environments/sample/main.tf @@ -40,6 +40,11 @@ module "aws_lambda" { arn = module.aws_dynamodb.link_table.arn } + ddb_link_nonce_table = { + name = module.aws_dynamodb.link_nonce_table.name + arn = module.aws_dynamodb.link_nonce_table.arn + } + role_redirect_request = { name = module.aws_iam.role_lambda_redirect_request.name arn = module.aws_iam.role_lambda_redirect_request.arn diff --git a/lambda/redirect_request/models/__init__.py b/lambda/redirect_request/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lambda/redirect_request/models/delibird_nonce.py b/lambda/redirect_request/models/delibird_nonce.py new file mode 100644 index 0000000..b28ad6a --- /dev/null +++ b/lambda/redirect_request/models/delibird_nonce.py @@ -0,0 +1,52 @@ +import os +from datetime import datetime +from typing import Optional + +from pynamodb.attributes import UnicodeAttribute, NumberAttribute +from pynamodb.constants import NULL +from pynamodb.exceptions import UpdateError +from pynamodb.expressions.operand import Path +from pynamodb.models import Model + +from ddb.datetime_attribute import DateTimeAttribute +from util.date_util import get_jst_datetime_now + +_REGION = os.environ["AWS_REGION"] + + +class DelibirdNonceTableModel(Model): + class Meta: + table_name = os.environ["NONCE_TABLE_NAME"] + region = _REGION + + nonce = UnicodeAttribute(hash_key=True) + expired_timestamp = NumberAttribute(null=False) + + domain = UnicodeAttribute(null=False) + slug = UnicodeAttribute(null=False) + used_at = DateTimeAttribute(null=True, default=None) + + def is_active(self) -> bool: + if self.used_at is not None: + # 使用済み + return False + if self.expired_timestamp <= get_jst_datetime_now().timestamp(): + # 期限切れ + return False + return True + + def mark_used(self) -> tuple[bool, Optional[datetime]]: + if not self.is_active(): + # usedな状態でmark_usedされていないか、呼び出し時点で期限切れしていないか + return False, None + try: + self.update( + actions=[DelibirdNonceTableModel.used_at.set(int(get_jst_datetime_now().timestamp()))], + condition=(DelibirdNonceTableModel.used_at.does_not_exist()) | + (Path(DelibirdNonceTableModel.used_at).is_type(NULL)) + ) + except UpdateError as e: + if e.cause_response_code == "ConditionalCheckFailedException": + return False, None + raise e + return True, now diff --git a/modules/aws_dynamodb/output.tf b/modules/aws_dynamodb/output.tf index d5bdf2c..a27fd8c 100644 --- a/modules/aws_dynamodb/output.tf +++ b/modules/aws_dynamodb/output.tf @@ -1,3 +1,7 @@ output "link_table" { value = aws_dynamodb_table.link_table } + +output "link_nonce_table" { + value = aws_dynamodb_table.link_nonce +} diff --git a/modules/aws_dynamodb/table.tf b/modules/aws_dynamodb/table.tf index 5be593b..1995dab 100644 --- a/modules/aws_dynamodb/table.tf +++ b/modules/aws_dynamodb/table.tf @@ -17,3 +17,22 @@ resource "aws_dynamodb_table" "link_table" { type = "S" } } + +resource "aws_dynamodb_table" "link_nonce" { + name = "Delibird-${var.environment}-DelibirdLinkNonceTable" + billing_mode = "PAY_PER_REQUEST" + + deletion_protection_enabled = true + + hash_key = "nonce" + + attribute { + name = "nonce" + type = "S" + } + + ttl { + attribute_name = "expired_timestamp" + enabled = true + } +} diff --git a/modules/aws_lambda/function.tf b/modules/aws_lambda/function.tf index c167d50..a2c4e76 100644 --- a/modules/aws_lambda/function.tf +++ b/modules/aws_lambda/function.tf @@ -29,6 +29,7 @@ resource "aws_lambda_function" "redirect_request" { DELIBIRD_ENV = var.environment ENV_VAR = var.environment_var LINK_TABLE_NAME = var.ddb_link_table.name + NONCE_TABLE_NAME = var.ddb_link_nonce_table.name STATIC_RESOURCE_DIR = "/opt/delibird/static" ALLOWED_DOMAIN = join(",", var.allowed_domain) MAX_QUERY_KEY_LENGTH = "100" diff --git a/modules/aws_lambda/variables.tf b/modules/aws_lambda/variables.tf index b73df13..46a82ee 100644 --- a/modules/aws_lambda/variables.tf +++ b/modules/aws_lambda/variables.tf @@ -34,6 +34,13 @@ variable "ddb_link_table" { }) } +variable "ddb_link_nonce_table" { + type = object({ + name = string + arn = string + }) +} + variable "role_redirect_request" { type = object({ name = string From 98e0d391f1705527f79ae1148307c525286af0b4 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 17:40:47 +0900 Subject: [PATCH 03/29] =?UTF-8?q?update:=20protected=E6=99=82=E3=81=ABnonc?= =?UTF-8?q?e=E3=81=A8challenge=E3=82=92=E7=9A=87=E6=97=8F=E3=81=AB?= =?UTF-8?q?=E6=B8=A1=E3=81=95=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lambda/redirect_request/app.py | 5 ++++- lambda/redirect_request/query.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lambda/redirect_request/app.py b/lambda/redirect_request/app.py index a77ae4b..4e81e63 100644 --- a/lambda/redirect_request/app.py +++ b/lambda/redirect_request/app.py @@ -10,6 +10,8 @@ from util.response_util import redirect_response, error_response logger = setup_logger("redirect_request") +_NONCE_QUERY_KEY = "n" +_CHALLENGE_QUERY_KEY = "c" @event_source(data_class=APIGatewayProxyEvent) @@ -92,7 +94,8 @@ def lambda_handler(event: APIGatewayProxyEvent, context: LambdaContext): if not link.query_omit: logger.info(f"Link(domain: {domain}, slug: {request_path}) has disabled query omission. Appending query parameters.") try: - origin = queried_origin(origin, event.resolved_query_string_parameters, link.query_whitelist) + origin = queried_origin(origin, event.resolved_query_string_parameters, link.query_whitelist, + {_NONCE_QUERY_KEY, _CHALLENGE_QUERY_KEY} if link.is_protected() else set()) except ValueError: logger.exception(f"Invalid query parameters for domain: {domain}, slug: {request_path}, URL: {origin}") return error_response(HTTPStatus.BAD_REQUEST) diff --git a/lambda/redirect_request/query.py b/lambda/redirect_request/query.py index 37e1344..b609164 100644 --- a/lambda/redirect_request/query.py +++ b/lambda/redirect_request/query.py @@ -7,7 +7,7 @@ _MAX_TOTAL_QUERY_PARAMS = int(os.environ["MAX_TOTAL_QUERY_PARAMS"]) -def queried_origin(origin: str, data: dict[str, list[str]], query_whitelist: set[str]) -> str: +def queried_origin(origin: str, data: dict[str, list[str]], query_whitelist: set[str], query_blacklist: set[str]) -> str: """クエリパラメータを付与したURLを返す""" parsed_url = urlparse(origin) existing_query_pairs = [(k, v) for k, v in parse_qsl(parsed_url.query, strict_parsing=True)] @@ -32,6 +32,10 @@ def queried_origin(origin: str, data: dict[str, list[str]], query_whitelist: set # ホワイトリストフィルタリング query_pairs = [(k, v) for k, v in query_pairs if k in query_whitelist] + if len(query_blacklist) > 0: + # ブラックリストフィルタリング + query_pairs = [(k, v) for k, v in query_pairs if k not in query_blacklist] + query_string = urlencode(query_pairs, doseq=False) return urlunparse(( parsed_url.scheme, From 8961327227d10a6d81d8be8bd23c6b814c8469d5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 17:43:21 +0900 Subject: [PATCH 04/29] fix: DelibirdNonceTablemodel.mark_used() --- lambda/redirect_request/models/delibird_nonce.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lambda/redirect_request/models/delibird_nonce.py b/lambda/redirect_request/models/delibird_nonce.py index b28ad6a..3716fa9 100644 --- a/lambda/redirect_request/models/delibird_nonce.py +++ b/lambda/redirect_request/models/delibird_nonce.py @@ -39,9 +39,11 @@ def mark_used(self) -> tuple[bool, Optional[datetime]]: if not self.is_active(): # usedな状態でmark_usedされていないか、呼び出し時点で期限切れしていないか return False, None + + now = get_jst_datetime_now() try: self.update( - actions=[DelibirdNonceTableModel.used_at.set(int(get_jst_datetime_now().timestamp()))], + actions=[DelibirdNonceTableModel.used_at.set(int(now.timestamp()))], condition=(DelibirdNonceTableModel.used_at.does_not_exist()) | (Path(DelibirdNonceTableModel.used_at).is_type(NULL)) ) From 607b8e3416cac9ddc0605591ee799a704f45d948 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 21:02:24 +0900 Subject: [PATCH 05/29] update: bootstrap icons to 1.13.1 --- lambda/admin_portal/static/links.html | 2 +- lambda/layers/common/python/util/response_util.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lambda/admin_portal/static/links.html b/lambda/admin_portal/static/links.html index d35da90..8265ba5 100644 --- a/lambda/admin_portal/static/links.html +++ b/lambda/admin_portal/static/links.html @@ -7,7 +7,7 @@ - + diff --git a/lambda/layers/common/python/util/response_util.py b/lambda/layers/common/python/util/response_util.py index 7463c40..fd4d4a9 100644 --- a/lambda/layers/common/python/util/response_util.py +++ b/lambda/layers/common/python/util/response_util.py @@ -33,14 +33,14 @@ def _build_csp_header(*, use_css: bool = False, use_bootstrap: bool = False, use style_origin = [ "https://static.kazutech.jp/l/css/" if use_css else None, "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/" if use_bootstrap else None, - "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/" if use_bootstrap else None, + "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/" if use_bootstrap else None, f"'nonce-{style_nonce}'" if style_nonce else None ] img_origin = [ "data:" if use_bootstrap else None, ] font_origin = [ - "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/" if use_bootstrap else None + "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/" if use_bootstrap else None ] connect_origin = [ "'self'" if use_self_api else None, From 706cb5ef44b3d0b222e8895494c0acbed73ba0b6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 21:31:07 +0900 Subject: [PATCH 06/29] add: error-protected.css --- .../aws_s3/{error_css.tf => stylesheet.tf} | 15 ++ s3/css/error-protected.css | 187 ++++++++++++++++++ 2 files changed, 202 insertions(+) rename modules/aws_s3/{error_css.tf => stylesheet.tf} (65%) create mode 100644 s3/css/error-protected.css diff --git a/modules/aws_s3/error_css.tf b/modules/aws_s3/stylesheet.tf similarity index 65% rename from modules/aws_s3/error_css.tf rename to modules/aws_s3/stylesheet.tf index aaf22a2..be5b23d 100644 --- a/modules/aws_s3/error_css.tf +++ b/modules/aws_s3/stylesheet.tf @@ -13,6 +13,21 @@ resource "aws_s3_object" "error_css" { etag = filemd5("${local.local_base_path}/css/error.css") } +resource "aws_s3_object" "error_protected_css" { + # アップロード元(ローカル) + source = "${local.local_base_path}/css/error-protected.css" + + # アップロード先(S3) + bucket = var.bucket_name + key = "${var.remote_base_path}/css/error-protected.css" + + # Content-Type の設定 + content_type = "text/css" + + # エンティティタグ (ファイル更新のトリガーに必要) + etag = filemd5("${local.local_base_path}/css/error-protected.css") +} + resource "aws_s3_object" "admin_portal_css" { # アップロード元(ローカル) source = "${local.local_base_path}/css/admin-portal.css" diff --git a/s3/css/error-protected.css b/s3/css/error-protected.css new file mode 100644 index 0000000..0e5f10a --- /dev/null +++ b/s3/css/error-protected.css @@ -0,0 +1,187 @@ +body.error-protected { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); +} + +body.error-protected .error-code { + font-size: 6rem; + color: #4facfe; +} + +body.error-protected .error-code i { + font-size: 5rem; +} + +.password-form { + margin: 2rem 0; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +.password-input { + width: 100%; + max-width: 350px; + padding: 0.875rem 1rem; + font-size: 1rem; + border: 2px solid #e0e0e0; + border-radius: 8px; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.password-input:focus { + outline: none; + border-color: #4facfe; + box-shadow: 0 0 0 3px rgba(79, 172, 254, 0.1); +} + +.password-input.input-error { + border-color: #f5576c; +} + +.password-input.input-error:focus { + box-shadow: 0 0 0 3px rgba(245, 87, 108, 0.1); +} + +.submit-button { + width: 100%; + max-width: 350px; + padding: 0.875rem 2rem; + font-size: 1rem; + font-weight: 600; + color: white; + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(79, 172, 254, 0.3); +} + +.submit-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(79, 172, 254, 0.4); +} + +.submit-button:active { + transform: translateY(0); + box-shadow: 0 2px 10px rgba(79, 172, 254, 0.3); +} + +.submit-button:disabled { + background: #ccc; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.form-error-message { + color: #f5576c; + font-size: 0.875rem; + margin-top: 0.5rem; + text-align: center; + font-weight: 500; +} + +@media (max-width: 600px) { + .password-input, + .submit-button { + max-width: 100%; + } + + .error-container { + padding: 2rem 1.5rem; + } +} + +/* Password Form Styles */ +.password-form { + margin: 2rem 0; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +.password-input { + width: 100%; + max-width: 350px; + padding: 0.875rem 1rem; + font-size: 1rem; + border: 2px solid #e0e0e0; + border-radius: 8px; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.password-input:focus { + outline: none; + border-color: #4facfe; + box-shadow: 0 0 0 3px rgba(79, 172, 254, 0.1); +} + +.password-input.input-error { + border-color: #f5576c; +} + +.password-input.input-error:focus { + box-shadow: 0 0 0 3px rgba(245, 87, 108, 0.1); +} + +.submit-button { + width: 100%; + max-width: 350px; + padding: 0.875rem 2rem; + font-size: 1rem; + font-weight: 600; + color: white; + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(79, 172, 254, 0.3); +} + +.submit-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(79, 172, 254, 0.4); +} + +.submit-button:active { + transform: translateY(0); + box-shadow: 0 2px 10px rgba(79, 172, 254, 0.3); +} + +.submit-button:disabled { + background: #ccc; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.form-error-message { + color: #f5576c; + font-size: 0.875rem; + margin-top: 0.5rem; + text-align: center; + font-weight: 500; +} + +@media (max-width: 600px) { + .password-input, + .submit-button { + max-width: 100%; + } + + .error-container { + padding: 2rem 1.5rem; + } +} \ No newline at end of file From 065897a55b89e706c8a76243bbcb077b3dd01e27 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 21:32:23 +0900 Subject: [PATCH 07/29] add: protected.html --- lambda/redirect_request/static/protected.html | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 lambda/redirect_request/static/protected.html diff --git a/lambda/redirect_request/static/protected.html b/lambda/redirect_request/static/protected.html new file mode 100644 index 0000000..4f13d12 --- /dev/null +++ b/lambda/redirect_request/static/protected.html @@ -0,0 +1,102 @@ + + + + + + 401 Unauthorized + + + + + + +
+

+

パスワード保護

+

+ このリンクはパスワードで保護されています。
+ パスワードを入力してください。 +

+ +
+
+ + +
+ +
+ +
+

© 2025 Kazuhiro Oka

+
+
+ + + + document.addEventListener('DOMContentLoaded', function () { + const form = document.getElementById('password-form'); + const passwordInput = document.getElementById('password-input'); + const errorMessage = document.getElementById('error-message'); + + const nonce = '{{ request_nonce }}'; + + form.addEventListener('submit', async function (e) { + e.preventDefault(); + if (!passwordInput.value || !passwordInput.value.match(/\S/g)) { + showError('パスワードを入力してください'); + return; + } + const challenge = await crypto.subtle.digest('SHA-256', (new TextEncoder()).encode(passwordInput.value + '#' + nonce)).then( + b => Array.from(new Uint8Array(b)).map((bb) => bb.toString(16).padStart(2, '0')).join('')) + + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.set('{{ challenge_query_key }}', challenge); + currentUrl.searchParams.set('{{ nonce_query_key }}', nonce); + + window.location.href = currentUrl.toString(); + }); + + function showError(message) { + errorMessage.textContent = message; + errorMessage.style.display = 'block'; + passwordInput.classList.add('input-error'); + + // エラーアニメーション + passwordInput.animate([ + {transform: 'translateX(0)'}, + {transform: 'translateX(-10px)'}, + {transform: 'translateX(10px)'}, + {transform: 'translateX(-10px)'}, + {transform: 'translateX(10px)'}, + {transform: 'translateX(0)'} + ], { + duration: 400, + easing: 'ease-in-out' + }); + } + + function hideError() { + errorMessage.style.display = 'none'; + passwordInput.classList.remove('input-error'); + } + + passwordInput.addEventListener('input', function () { + if (errorMessage.style.display !== 'none') { + hideError(); + } + }); + }); + + + + From efbbc6e55ef880e12e46cb1c74d85136630e40aa Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 21:36:40 +0900 Subject: [PATCH 08/29] add: response_util use_bootstrap_icons --- .../layers/common/python/util/response_util.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lambda/layers/common/python/util/response_util.py b/lambda/layers/common/python/util/response_util.py index fd4d4a9..0396243 100644 --- a/lambda/layers/common/python/util/response_util.py +++ b/lambda/layers/common/python/util/response_util.py @@ -24,7 +24,7 @@ def _load_error_html(status: HTTPStatus) -> tuple[Optional[str], bool]: return html_content, html_content is not None -def _build_csp_header(*, use_css: bool = False, use_bootstrap: bool = False, use_self_api: bool = False, +def _build_csp_header(*, use_css: bool = False, use_bootstrap: bool = False, use_bootstrap_icons: bool = False, use_self_api: bool = False, style_nonce: str = None, script_nonce: str = None) -> str: script_origin = [ "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/" if use_bootstrap else None, @@ -33,14 +33,14 @@ def _build_csp_header(*, use_css: bool = False, use_bootstrap: bool = False, use style_origin = [ "https://static.kazutech.jp/l/css/" if use_css else None, "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/" if use_bootstrap else None, - "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/" if use_bootstrap else None, + "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/" if (use_bootstrap_icons or use_bootstrap) else None, f"'nonce-{style_nonce}'" if style_nonce else None ] img_origin = [ - "data:" if use_bootstrap else None, + "data:" if (use_bootstrap_icons or use_bootstrap) else None, ] font_origin = [ - "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/" if use_bootstrap else None + "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/" if (use_bootstrap_icons or use_bootstrap) else None ] connect_origin = [ "'self'" if use_self_api else None, @@ -68,12 +68,12 @@ def _build_csp_header(*, use_css: bool = False, use_bootstrap: bool = False, use def _generate_response_headers(content_type: str = None, *, - use_css: bool = False, use_bootstrap: bool = False, use_self_api: bool = False, + use_css: bool = False, use_bootstrap: bool = False, use_bootstrap_icons: bool = False, use_self_api: bool = False, style_nonce: str = None, script_nonce: str = None) -> dict[str, str]: headers = { "Content-Type": content_type or "application/json;charset=utf-8", "Content-Security-Policy": _build_csp_header( - use_css=use_css, use_bootstrap=use_bootstrap, use_self_api=use_self_api, + use_css=use_css, use_bootstrap=use_bootstrap, use_bootstrap_icons=use_bootstrap_icons, use_self_api=use_self_api, style_nonce=style_nonce, script_nonce=script_nonce), "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate", "Pragma": "no-cache", @@ -110,13 +110,13 @@ def error_response(status: HTTPStatus, force_json: bool = False): def success_response(status: HTTPStatus, body: str | dict, content_type: str = None, *, - use_css: bool = False, use_bootstrap: bool = False, use_self_api: bool = False, + use_css: bool = False, use_bootstrap: bool = False, use_bootstrap_icons: bool = False, use_self_api: bool = False, style_nonce: str = None, script_nonce: str = None): return { "statusCode": status.value, "headers": _generate_response_headers( content_type, - use_css=use_css, use_bootstrap=use_bootstrap, use_self_api=use_self_api, + use_css=use_css, use_bootstrap=use_bootstrap, use_bootstrap_icons=use_bootstrap_icons, use_self_api=use_self_api, style_nonce=style_nonce, script_nonce=script_nonce), "body": body if isinstance(body, str) else json.dumps(body), } From a42c00bf915fb4e07cc4b8403fa5fbf4448d19b2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 21:43:04 +0900 Subject: [PATCH 09/29] add: lambda->nonce-table iam policy --- environments/sample/main.tf | 5 +++++ modules/aws_iam/lambda_redirect_request.tf | 26 ++++++++++++++++++++++ modules/aws_iam/variables.tf | 7 ++++++ 3 files changed, 38 insertions(+) diff --git a/environments/sample/main.tf b/environments/sample/main.tf index 44aac0d..4f55524 100644 --- a/environments/sample/main.tf +++ b/environments/sample/main.tf @@ -14,6 +14,11 @@ module "aws_iam" { arn = module.aws_dynamodb.link_table.arn } + ddb_link_nonce_table = { + name = module.aws_dynamodb.link_nonce_table.name + arn = module.aws_dynamodb.link_nonce_table.arn + } + lambda_redirect_request = { name = module.aws_lambda.lambda_redirect_request.function_name arn = module.aws_lambda.lambda_redirect_request.arn diff --git a/modules/aws_iam/lambda_redirect_request.tf b/modules/aws_iam/lambda_redirect_request.tf index a45ead0..58394f4 100644 --- a/modules/aws_iam/lambda_redirect_request.tf +++ b/modules/aws_iam/lambda_redirect_request.tf @@ -50,6 +50,32 @@ resource "aws_iam_role_policy" "lambda_redirect_request" { } } }, + { + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + ] + Resource = [ + var.ddb_link_nonce_table.arn, + ] + }, + { + Effect = "Allow" + Action = [ + "dynamodb:UpdateItem", + ] + Resource = [ + var.ddb_link_nonce_table.arn, + ] + Condition = { + "ForAllValues:StringEquals" = { + "dynamodb:Attributes" = [ + "nonce", + "used_at", + ] + } + } + }, ] }) } diff --git a/modules/aws_iam/variables.tf b/modules/aws_iam/variables.tf index a402477..d0a28f0 100644 --- a/modules/aws_iam/variables.tf +++ b/modules/aws_iam/variables.tf @@ -10,6 +10,13 @@ variable "ddb_link_table" { }) } +variable "ddb_link_nonce_table" { + type = object({ + name = string + arn = string + }) +} + variable "lambda_redirect_request" { type = object({ name = string From 3bdeec17babac054229d886cfd753c1a9a90de9f Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 21:48:54 +0900 Subject: [PATCH 10/29] fix: bootstrap icons integrity --- lambda/admin_portal/static/links.html | 2 +- lambda/redirect_request/static/protected.html | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lambda/admin_portal/static/links.html b/lambda/admin_portal/static/links.html index 8265ba5..830496b 100644 --- a/lambda/admin_portal/static/links.html +++ b/lambda/admin_portal/static/links.html @@ -7,7 +7,7 @@ - + diff --git a/lambda/redirect_request/static/protected.html b/lambda/redirect_request/static/protected.html index 4f13d12..9dd4475 100644 --- a/lambda/redirect_request/static/protected.html +++ b/lambda/redirect_request/static/protected.html @@ -4,7 +4,8 @@ 401 Unauthorized - + From d2e6cc2cd866fd65222970c0b0bcb1d764a5b7a2 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 22:18:36 +0900 Subject: [PATCH 11/29] add: NONCE_LIFETIME_SECONDS --- environments/sample/main.tf | 7 ++++--- modules/aws_lambda/function.tf | 1 + modules/aws_lambda/variables.tf | 5 +++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/environments/sample/main.tf b/environments/sample/main.tf index 4f55524..96beae9 100644 --- a/environments/sample/main.tf +++ b/environments/sample/main.tf @@ -60,9 +60,10 @@ module "aws_lambda" { arn = module.aws_iam.role_lambda_admin_portal.arn } - link_prefix = "" # TODO: Change value as needed (e.g. https://example.com/dev/.... -> "dev") - allowed_domain = local.config["allowed_domain"] - reserved_concurrent_executions = local.config["aws"]["lambda"]["reserved_concurrent_executions"] + link_prefix = "" # TODO: Change value as needed (e.g. https://example.com/dev/.... -> "dev") + allowed_domain = local.config["allowed_domain"] + protected_link_request_nonce_lifetime = 300 # TODO: Change value as needed + reserved_concurrent_executions = local.config["aws"]["lambda"]["reserved_concurrent_executions"] } module "aws_s3" { diff --git a/modules/aws_lambda/function.tf b/modules/aws_lambda/function.tf index a2c4e76..b11baa6 100644 --- a/modules/aws_lambda/function.tf +++ b/modules/aws_lambda/function.tf @@ -30,6 +30,7 @@ resource "aws_lambda_function" "redirect_request" { ENV_VAR = var.environment_var LINK_TABLE_NAME = var.ddb_link_table.name NONCE_TABLE_NAME = var.ddb_link_nonce_table.name + NONCE_LIFETIME_SECONDS = var.protected_link_request_nonce_lifetime STATIC_RESOURCE_DIR = "/opt/delibird/static" ALLOWED_DOMAIN = join(",", var.allowed_domain) MAX_QUERY_KEY_LENGTH = "100" diff --git a/modules/aws_lambda/variables.tf b/modules/aws_lambda/variables.tf index 46a82ee..906492b 100644 --- a/modules/aws_lambda/variables.tf +++ b/modules/aws_lambda/variables.tf @@ -64,6 +64,11 @@ variable "allowed_domain" { type = list(string) } +variable "protected_link_request_nonce_lifetime" { + description = "Lifetime of nonce for protected link requests in seconds" + type = number +} + variable "reserved_concurrent_executions" { description = "Reserved concurrent executions for Lambda function (for cost and DDoS protection)" type = number From 6e53fb8403fb6f8300932b691e13f3d4be1e33a5 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 22:19:00 +0900 Subject: [PATCH 12/29] add: putitem action to policy lambda->nonce-table --- modules/aws_iam/lambda_redirect_request.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/aws_iam/lambda_redirect_request.tf b/modules/aws_iam/lambda_redirect_request.tf index 58394f4..b40d492 100644 --- a/modules/aws_iam/lambda_redirect_request.tf +++ b/modules/aws_iam/lambda_redirect_request.tf @@ -54,6 +54,7 @@ resource "aws_iam_role_policy" "lambda_redirect_request" { Effect = "Allow" Action = [ "dynamodb:GetItem", + "dynamodb:PutItem", ] Resource = [ var.ddb_link_nonce_table.arn, From 2e980b7d3bbe22ee178aa5ff643b4c913cf9014f Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 22:19:26 +0900 Subject: [PATCH 13/29] add: default_error_message to protected.html --- lambda/redirect_request/static/protected.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lambda/redirect_request/static/protected.html b/lambda/redirect_request/static/protected.html index 9dd4475..d8427f2 100644 --- a/lambda/redirect_request/static/protected.html +++ b/lambda/redirect_request/static/protected.html @@ -50,6 +50,7 @@

const errorMessage = document.getElementById('error-message'); const nonce = '{{ request_nonce }}'; + const default_error_message = '{{ default_error_message }}' form.addEventListener('submit', async function (e) { e.preventDefault(); @@ -67,6 +68,10 @@

window.location.href = currentUrl.toString(); }); + if (default_error_message) { + showError(default_error_message); + } + function showError(message) { errorMessage.textContent = message; errorMessage.style.display = 'block'; From 372701475290a5a94ebc3769eb72d423460ac4f7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 22:26:08 +0900 Subject: [PATCH 14/29] fix: DelibirdNonceTableModel.mark_used --- lambda/redirect_request/models/delibird_nonce.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambda/redirect_request/models/delibird_nonce.py b/lambda/redirect_request/models/delibird_nonce.py index 3716fa9..267b21c 100644 --- a/lambda/redirect_request/models/delibird_nonce.py +++ b/lambda/redirect_request/models/delibird_nonce.py @@ -43,7 +43,7 @@ def mark_used(self) -> tuple[bool, Optional[datetime]]: now = get_jst_datetime_now() try: self.update( - actions=[DelibirdNonceTableModel.used_at.set(int(now.timestamp()))], + actions=[DelibirdNonceTableModel.used_at.set(now)], condition=(DelibirdNonceTableModel.used_at.does_not_exist()) | (Path(DelibirdNonceTableModel.used_at).is_type(NULL)) ) From cfd8705a508bada8076ce64cc98dd8b989ccee90 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 22:28:48 +0900 Subject: [PATCH 15/29] =?UTF-8?q?add:=20=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layers/common/python/util/parse_util.py | 11 ++++ lambda/redirect_request/app.py | 55 +++++++++++++++++-- lambda/redirect_request/protected_util.py | 44 +++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 lambda/redirect_request/protected_util.py diff --git a/lambda/layers/common/python/util/parse_util.py b/lambda/layers/common/python/util/parse_util.py index 7b3b7e2..6dcaae1 100644 --- a/lambda/layers/common/python/util/parse_util.py +++ b/lambda/layers/common/python/util/parse_util.py @@ -29,3 +29,14 @@ def parse_domain(domain: str) -> str: if (not domain) or (domain not in _ALLOWED_DOMAIN): raise ValueError(f"Invalid or missing domain: {domain}, allowed: {_ALLOWED_DOMAIN}") return domain + + +def parse_query(query_data: dict[str, list[str]], key: str, *, allow_notfound: bool = False, expected_single_value: bool = True) -> str | list[str]: + if (key not in query_data) and (not allow_notfound): + raise ValueError(f"Invalid query key: {key}") + + v = query_data[key] + if expected_single_value and len(v) != 1: + raise ValueError(f"Invalid query value: {v}") + + return v if (not expected_single_value) else v[0] diff --git a/lambda/redirect_request/app.py b/lambda/redirect_request/app.py index 4e81e63..7a2d423 100644 --- a/lambda/redirect_request/app.py +++ b/lambda/redirect_request/app.py @@ -4,14 +4,14 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from ddb.models.delibird_link import DelibirdLinkTableModel, DelibirdLinkInactiveStatus +from models.delibird_nonce import DelibirdNonceTableModel +from protected_util import protected_response, NONCE_QUERY_KEY, CHALLENGE_QUERY_KEY from query import queried_origin from util.logger_util import setup_logger -from util.parse_util import parse_request_path, parse_origin, parse_domain +from util.parse_util import parse_request_path, parse_origin, parse_domain, parse_query from util.response_util import redirect_response, error_response logger = setup_logger("redirect_request") -_NONCE_QUERY_KEY = "n" -_CHALLENGE_QUERY_KEY = "c" @event_source(data_class=APIGatewayProxyEvent) @@ -80,6 +80,41 @@ def lambda_handler(event: APIGatewayProxyEvent, context: LambdaContext): logger.error(f"Link(domain: {domain}, slug: {request_path}) has invalid status code: {link.status}") return error_response(HTTPStatus.INTERNAL_SERVER_ERROR) + # リンクがパスフレーズ付きの場合 + nonce_model = None + if link.is_protected(): + ## nonceかchallengeがなかったら、未認証として認証ページを表示する。 + if (NONCE_QUERY_KEY not in event.resolved_query_string_parameters) and (CHALLENGE_QUERY_KEY not in event.resolved_query_string_parameters): + logger.info(f"Link(domain: {domain}, slug: {request_path}) requires challenge.") + try: + return protected_response(domain, request_path) + except Exception: + logger.exception(f"Failed to generate protected response for domain: {domain}, slug: {request_path}") + return error_response(HTTPStatus.INTERNAL_SERVER_ERROR) + try: + ## (nonceとchallengeがあったら、) まずnonceを取り出してDBと突合(存在確認・期限内・未使用・nonceの対象リクエストか) + nonce: str = parse_query(event.resolved_query_string_parameters, NONCE_QUERY_KEY, expected_single_value=True) + nonce_model = DelibirdNonceTableModel.get(nonce) + if not nonce_model.is_active(): + # nonceが使用済 or 期限切れ + return protected_response(domain, request_path, "認証に失敗しました。もう一度お試しください。") + if nonce_model.domain != domain or nonce_model.slug != request_path: + return protected_response(domain, request_path, "認証に失敗しました。もう一度お試しください。") + + ## 問題ないnonceの場合、challengeの確認 + challenge = parse_query(event.resolved_query_string_parameters, CHALLENGE_QUERY_KEY, expected_single_value=True) + if not link.validate_challenge(nonce, challenge): + logger.info(f"Invalid challenge for domain: {domain}, slug: {request_path}") + nonce_model.mark_used() # not successでもok + return protected_response(domain, request_path, "パスワードが正しくありません。") + except DelibirdNonceTableModel.DoesNotExist: + # nonceがDB上に存在しない + logger.info(f"Invalid nonce for domain: {domain}, slug: {request_path}") + return protected_response(domain, request_path, "認証に失敗しました。もう一度お試しください。") + except Exception: + logger.exception(f"Failed to parse challenge for domain: {domain}, slug: {request_path}") + return error_response(HTTPStatus.INTERNAL_SERVER_ERROR) + # リダイレクト先URLの決定 origin_candidate = interrupt_origin or link.link_origin try: @@ -95,7 +130,7 @@ def lambda_handler(event: APIGatewayProxyEvent, context: LambdaContext): logger.info(f"Link(domain: {domain}, slug: {request_path}) has disabled query omission. Appending query parameters.") try: origin = queried_origin(origin, event.resolved_query_string_parameters, link.query_whitelist, - {_NONCE_QUERY_KEY, _CHALLENGE_QUERY_KEY} if link.is_protected() else set()) + {NONCE_QUERY_KEY, CHALLENGE_QUERY_KEY} if link.is_protected() else set()) except ValueError: logger.exception(f"Invalid query parameters for domain: {domain}, slug: {request_path}, URL: {origin}") return error_response(HTTPStatus.BAD_REQUEST) @@ -103,6 +138,18 @@ def lambda_handler(event: APIGatewayProxyEvent, context: LambdaContext): logger.exception(f"Failed to append query parameters for domain: {domain}, slug: {request_path}, URL: {origin}") return error_response(HTTPStatus.INTERNAL_SERVER_ERROR) + # 使用したnonceの消込 + if nonce_model: + try: + nonce_use_success, nonce_used_at = nonce_model.mark_used() + except Exception: + logger.exception(f"Failed to mark nonce as used for domain: {domain}, slug: {request_path}") + return error_response(HTTPStatus.INTERNAL_SERVER_ERROR) + if not nonce_use_success: + # nonceがぎりぎり期限切れになった場合 or 競合リクエストで使用された場合 + logger.info(f"Failed to mark nonce as used for domain: {domain}, slug: {request_path}") + return protected_response(domain, request_path, "認証に失敗しました。もう一度お試しください。") + # リンクの使用回数をインクリメント try: update_success = link.increment_uses() diff --git a/lambda/redirect_request/protected_util.py b/lambda/redirect_request/protected_util.py new file mode 100644 index 0000000..cebf236 --- /dev/null +++ b/lambda/redirect_request/protected_util.py @@ -0,0 +1,44 @@ +import os +from datetime import timedelta +from http import HTTPStatus + +from models.delibird_nonce import DelibirdNonceTableModel +from util.date_util import get_jst_datetime_now +from util.nonce_util import create_nonce +from util.response_util import success_response +from util.static_resource_util import load_function_html + +NONCE_QUERY_KEY = "n" +CHALLENGE_QUERY_KEY = "c" + +_NONCE_LIFETIME_SECONDS = int(os.environ["NONCE_LIFETIME_SECONDS"]) + + +def protected_response(domain: str, slug: str, error_message: str = ""): + script_nonce = create_nonce() + request_nonce = _create_protected_request_nonce(domain, slug) + + html_content = load_function_html("static/protected.html", { + "default_error_message": error_message, + + "challenge_query_key": CHALLENGE_QUERY_KEY, + "nonce_query_key": NONCE_QUERY_KEY, + "script_nonce": script_nonce, + "request_nonce": request_nonce + }) + + return success_response(HTTPStatus.UNAUTHORIZED, html_content, + content_type='text/html;charset=utf-8', use_css=True, use_bootstrap_icons=True, + script_nonce=script_nonce) + + +def _create_protected_request_nonce(domain: str, slug: str) -> str: + nonce = create_nonce() + + model = DelibirdNonceTableModel(nonce) + model.domain = domain + model.slug = slug + model.expired_timestamp = int((get_jst_datetime_now() + timedelta(seconds=30)).timestamp()) + model.save(condition=DelibirdNonceTableModel.nonce.does_not_exist()) + + return nonce From a80632e3d5e8b7c5a9536f80dc776511bef48f31 Mon Sep 17 00:00:00 2001 From: Kazuhiro Oka Date: Sat, 20 Dec 2025 22:33:43 +0900 Subject: [PATCH 16/29] fix: css to hidden-field --- lambda/redirect_request/static/protected.html | 10 +- s3/css/error-protected.css | 91 +------------------ 2 files changed, 9 insertions(+), 92 deletions(-) diff --git a/lambda/redirect_request/static/protected.html b/lambda/redirect_request/static/protected.html index d8427f2..5271da8 100644 --- a/lambda/redirect_request/static/protected.html +++ b/lambda/redirect_request/static/protected.html @@ -34,7 +34,7 @@

送信 - +