diff --git a/environments/sample/main.tf b/environments/sample/main.tf
index 1adb2f3..96beae9 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
@@ -40,6 +45,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
@@ -50,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/lambda/admin_portal/app.py b/lambda/admin_portal/app.py
index 6d5da58..ae03de9 100644
--- a/lambda/admin_portal/app.py
+++ b/lambda/admin_portal/app.py
@@ -6,11 +6,12 @@
from portal_page.link_create import PortalLinkCreatePage
from portal_page.link_list import PortalListPage
from portal_page.link_update import PortalLinkUpdatePage
-from util.logger_util import setup_logger
+from util.logger_util import setup_logger, setup_dev_logger
from util.parse_util import parse_domain, parse_request_path
from util.response_util import error_response
logger = setup_logger("admin_portal")
+setup_dev_logger()
@event_source(data_class=APIGatewayProxyEvent)
diff --git a/lambda/admin_portal/portal_page/link_create.py b/lambda/admin_portal/portal_page/link_create.py
index 495dfd7..2dd2841 100644
--- a/lambda/admin_portal/portal_page/link_create.py
+++ b/lambda/admin_portal/portal_page/link_create.py
@@ -37,6 +37,7 @@ def _parse_request_data(cls, domain: str, _body: str) -> Optional[DelibirdLink]:
tag=set(body["tag"]) if "tag" in body else None,
expiration_date=as_jst(datetime.fromisoformat(str(body["expiration_date"]))) if "expiration_date" in body else None,
expired_origin=str(body["expired_origin"]) if "expired_origin" in body else None,
+ _passphrase=str(body["passphrase"]) if "passphrase" in body else None,
query_omit=bool(body["query_omit"]),
query_whitelist=set(body["query_whitelist"]) if "query_whitelist" in body else None,
max_uses=int(body["max_uses"]) if "max_uses" in body else None,
@@ -84,6 +85,7 @@ def perform(cls, domain: str, event: APIGatewayProxyEvent):
tag=link_data.tag,
expiration_date=link_data.expiration_date,
expired_origin=link_data.expired_origin,
+ passphrase=link_data._passphrase, # allow read private field
query_omit=link_data.query_omit,
query_whitelist=link_data.query_whitelist,
max_uses=link_data.max_uses
diff --git a/lambda/admin_portal/portal_page/link_update.py b/lambda/admin_portal/portal_page/link_update.py
index 03a3cb9..8556c72 100644
--- a/lambda/admin_portal/portal_page/link_update.py
+++ b/lambda/admin_portal/portal_page/link_update.py
@@ -36,6 +36,7 @@ def _parse_request_data(cls, domain: str, _body: str) -> Optional[DelibirdLink]:
tag=set(body["tag"]) if "tag" in body else None,
expiration_date=as_jst(datetime.fromisoformat(str(body["expiration_date"]))) if "expiration_date" in body else None,
expired_origin=str(body["expired_origin"]) if "expired_origin" in body else None,
+ _passphrase=str(body["passphrase"]) if "passphrase" in body else None,
query_omit=bool(body["query_omit"]),
query_whitelist=set(body["query_whitelist"]) if "query_whitelist" in body else None,
max_uses=int(body["max_uses"]) if "max_uses" in body else None,
@@ -77,6 +78,7 @@ def perform(cls, domain: str, event: APIGatewayProxyEvent):
DelibirdLinkTableModel.tag.set(link_data.tag),
DelibirdLinkTableModel.expiration_date.set(link_data.expiration_date),
DelibirdLinkTableModel.expired_origin.set(link_data.expired_origin),
+ DelibirdLinkTableModel.passphrase.set(link_data._passphrase), # allow read private field
DelibirdLinkTableModel.query_omit.set(link_data.query_omit),
DelibirdLinkTableModel.query_whitelist.set(link_data.query_whitelist),
DelibirdLinkTableModel.max_uses.set(link_data.max_uses),
diff --git a/lambda/admin_portal/static/links.html b/lambda/admin_portal/static/links.html
index d35da90..2a0b5a4 100644
--- a/lambda/admin_portal/static/links.html
+++ b/lambda/admin_portal/static/links.html
@@ -7,7 +7,7 @@
-
+
@@ -110,6 +110,11 @@ Link Details
data-bs-html="true"
title="{{ link.tag|join(', ') }}">
{% endif %}
+ {% if link.is_protected() %}
+
+ {% endif %}
@@ -201,6 +206,7 @@ Link Details
data-max-uses="{{ link.max_uses or '' }}"
data-expiration="{{ link.expiration_date.strftime('%Y-%m-%dT%H:%M') if link.expiration_date else '' }}"
data-expired-origin="{{ link.expired_origin or '' }}"
+ data-passphrase="{{ link._passphrase or '' }}"
data-query-omit="{{ 'true' if link.query_omit else 'false' }}"
data-query-whitelist="{{ link.query_whitelist|join(',') if link.query_whitelist else '' }}"
data-memo="{{ link.memo or '' }}"
@@ -320,6 +326,19 @@
期限切れ時のリダイレクト先URL(オプション)
+
+
+
+
+
+
+
+
リンクにアクセスするためのパスワード(オプション)
+
+
@@ -444,6 +463,19 @@
期限切れ時のリダイレクト先URL(オプション)
+
+
+
+
+
+
+
+
リンクにアクセスするためのパスワード(オプション)
+
+
@@ -483,7 +515,7 @@
-
diff --git a/lambda/layers/common/python/ddb/models/delibird_link.py b/lambda/layers/common/python/ddb/models/delibird_link.py
index 80dae1a..226b20e 100644
--- a/lambda/layers/common/python/ddb/models/delibird_link.py
+++ b/lambda/layers/common/python/ddb/models/delibird_link.py
@@ -1,3 +1,5 @@
+import hashlib
+import hmac
import json
import logging
import os
@@ -53,6 +55,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
@@ -72,6 +76,9 @@ def _validation(link: "DelibirdLink") -> None:
# status
if not link.status.is_redirection:
raise ValueError(f"Status code {link.status} is not a redirection status.")
+ # passphrase
+ if (link._passphrase is not None) and link._passphrase.isspace():
+ link._passphrase = None
def __post_init__(self):
if self.query_whitelist is None:
@@ -109,6 +116,17 @@ def increment_uses(self) -> bool:
self.uses += 1
return True
+ def is_protected(self) -> bool:
+ return (self._passphrase is not None) and (not self._passphrase.isspace())
+
+ 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 hmac.compare_digest(challenge, expected)
+
@staticmethod
def from_model(model: "DelibirdLinkTableModel") -> "DelibirdLink":
return DelibirdLink(
@@ -123,6 +141,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 +166,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/lambda/layers/common/python/util/logger_util.py b/lambda/layers/common/python/util/logger_util.py
index be594b5..3a97097 100644
--- a/lambda/layers/common/python/util/logger_util.py
+++ b/lambda/layers/common/python/util/logger_util.py
@@ -1,4 +1,5 @@
import logging
+import os
import sys
_FORMAT = '%(levelname)s %(asctime)s [%(name)s - %(funcName)s] %(message)s'
@@ -32,3 +33,11 @@ def setup_logger(name: str, level: int = logging.DEBUG):
logger.addHandler(_ERROR_HANDLER)
# logger.addHandler(_LOG_FILE_HANDLER)
return logger
+
+
+def setup_dev_logger():
+ if os.environ.get("DELIBIRD_ENV") != "dev":
+ return
+ log_pynamodb = logging.getLogger("pynamodb")
+ log_pynamodb.setLevel(logging.DEBUG)
+ log_pynamodb.addHandler(_HANDLER)
diff --git a/lambda/layers/common/python/util/parse_util.py b/lambda/layers/common/python/util/parse_util.py
index 7b3b7e2..56764ce 100644
--- a/lambda/layers/common/python/util/parse_util.py
+++ b/lambda/layers/common/python/util/parse_util.py
@@ -1,4 +1,5 @@
import os
+from typing import Optional
from urllib.parse import quote
from urllib.parse import urlparse
@@ -29,3 +30,17 @@ 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) \
+ -> Optional[str | list[str]]:
+ if key not in query_data:
+ if allow_notfound:
+ return None
+ 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/layers/common/python/util/response_util.py b/lambda/layers/common/python/util/response_util.py
index 7463c40..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.11.3/" 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.11.3/" 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),
}
diff --git a/lambda/redirect_request/app.py b/lambda/redirect_request/app.py
index a77ae4b..c92e896 100644
--- a/lambda/redirect_request/app.py
+++ b/lambda/redirect_request/app.py
@@ -4,12 +4,15 @@
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.logger_util import setup_logger, setup_dev_logger
+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")
+setup_dev_logger()
@event_source(data_class=APIGatewayProxyEvent)
@@ -78,6 +81,52 @@ 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)
+ # リンクがパスフレーズ付きの場合
+ 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, "パスワードが正しくありません。")
+
+ # チャレンジ成功 -> nonce消込
+ 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, "認証に失敗しました。もう一度お試しください。")
+
+ 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:
@@ -92,7 +141,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/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..267b21c
--- /dev/null
+++ b/lambda/redirect_request/models/delibird_nonce.py
@@ -0,0 +1,54 @@
+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
+
+ now = get_jst_datetime_now()
+ try:
+ self.update(
+ actions=[DelibirdNonceTableModel.used_at.set(now)],
+ 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/lambda/redirect_request/protected_util.py b/lambda/redirect_request/protected_util.py
new file mode 100644
index 0000000..ecc4726
--- /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=_NONCE_LIFETIME_SECONDS)).timestamp())
+ model.save(condition=DelibirdNonceTableModel.nonce.does_not_exist())
+
+ return nonce
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,
diff --git a/lambda/redirect_request/static/protected.html b/lambda/redirect_request/static/protected.html
new file mode 100644
index 0000000..a26af32
--- /dev/null
+++ b/lambda/redirect_request/static/protected.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+ 401 Unauthorized
+
+
+
+
+
+
+
+
+
パスワード保護
+
+ このリンクはパスワードで保護されています。
+ パスワードを入力してください。
+
+
+
+
+
+
+
+
+
+
+
+
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_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"
]
diff --git a/modules/aws_iam/lambda_redirect_request.tf b/modules/aws_iam/lambda_redirect_request.tf
index a45ead0..b40d492 100644
--- a/modules/aws_iam/lambda_redirect_request.tf
+++ b/modules/aws_iam/lambda_redirect_request.tf
@@ -50,6 +50,33 @@ resource "aws_iam_role_policy" "lambda_redirect_request" {
}
}
},
+ {
+ Effect = "Allow"
+ Action = [
+ "dynamodb:GetItem",
+ "dynamodb:PutItem",
+ ]
+ 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
diff --git a/modules/aws_lambda/function.tf b/modules/aws_lambda/function.tf
index c167d50..b11baa6 100644
--- a/modules/aws_lambda/function.tf
+++ b/modules/aws_lambda/function.tf
@@ -29,6 +29,8 @@ 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
+ 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 b73df13..906492b 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
@@ -57,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
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/admin-portal.css b/s3/css/admin-portal.css
index 7d26cb9..e5e7bcd 100644
--- a/s3/css/admin-portal.css
+++ b/s3/css/admin-portal.css
@@ -118,4 +118,41 @@
.bg-expired {
background-color: #6f42c1;
-}
\ No newline at end of file
+}
+
+.password-field-wrapper {
+ position: relative;
+}
+
+.password-toggle-btn {
+ position: absolute;
+ top: 50%;
+ right: 10px;
+ transform: translateY(-50%);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: #6c757d;
+ padding: 4px 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+}
+
+.password-toggle-btn:hover {
+ color: #495057;
+}
+
+.password-toggle-btn:focus {
+ outline: none;
+ color: #0d6efd;
+}
+
+.password-toggle-btn i {
+ font-size: 1.1rem;
+}
+
+.password-input-field {
+ padding-right: 2.5rem;
+}
diff --git a/s3/css/error-protected.css b/s3/css/error-protected.css
new file mode 100644
index 0000000..10d6eec
--- /dev/null
+++ b/s3/css/error-protected.css
@@ -0,0 +1,140 @@
+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;
+}
+
+.hidden-field {
+ display: none !important;
+}
+
+.password-form {
+ margin: 2rem 0;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-items: center;
+}
+
+.password-field {
+ position: relative;
+ width: 100%;
+ max-width: 350px;
+}
+
+.toggle-password {
+ position: absolute;
+ top: 50%;
+ right: 12px;
+ transform: translateY(-50%);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: #777;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.toggle-password:focus-visible {
+ outline: 2px solid #4facfe;
+ border-radius: 4px;
+}
+
+.toggle-password i {
+ font-size: 1.1rem;
+}
+
+.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;
+ padding-right: 2.75rem;
+}
+
+.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%;
+ }
+
+ .password-field,
+ .password-input {
+ max-width: 100%;
+ }
+
+ .error-container {
+ padding: 2rem 1.5rem;
+ }
+}
+