diff --git a/pyrelay/relay/handlers/send_event_handler.py b/pyrelay/relay/handlers/send_event_handler.py index 321b061..2c47cd0 100644 --- a/pyrelay/relay/handlers/send_event_handler.py +++ b/pyrelay/relay/handlers/send_event_handler.py @@ -1,3 +1,5 @@ +import time + from pyrelay.nostr.event import EventKind, NostrEvent from pyrelay.nostr.msgs import NostrCommandResults from pyrelay.relay.client_session import ClientSession @@ -31,6 +33,11 @@ async def _save_event(repo: EventsRepository, event: NostrEvent) -> NostrCommand if nips_config.nip_9 and event.kind == EventKind.EventDeletion: # type: ignore await _handle_delete_event(repo, event) + if not is_creation_time_valid(event.created_at, time.time(), nips_config.nip_22): + return NostrCommandResults( + event_id=event.id, saved=False, message="invalid: timestamp is not in required time frame" + ) + try: # todo: add duplicate validation await repo.add(event) @@ -45,3 +52,10 @@ async def _save_event(repo: EventsRepository, event: NostrEvent) -> NostrCommand async def _handle_delete_event(repo: EventsRepository, event: NostrEvent): keys_to_delete = event.e_tags await repo.delete(keys_to_delete) + + +def is_creation_time_valid(event_creation_time: int, current_time: float, nip_22_config: tuple[int, int]) -> bool: + if not nip_22_config: + return True + creation_time_offset = event_creation_time - current_time + return int(creation_time_offset) in range(*nip_22_config) diff --git a/pyrelay/relay/nip_config.py b/pyrelay/relay/nip_config.py index dd300cf..3a1482f 100644 --- a/pyrelay/relay/nip_config.py +++ b/pyrelay/relay/nip_config.py @@ -1,5 +1,9 @@ from pydantic import BaseModel +# timestamp limits relative to current time +LOWER_TIMESTAMP_OFFSET_LIMIT = - 60 * 60 * 24 # allow up to one day into the past - inclusive +UPPER_TIMESTAMP_OFFSET_LIMIT = 60 * 15 # allow up to 15 minutes into the future - exclusive + class NIPConfig(BaseModel): # NIP-9: Event deletion @@ -20,8 +24,8 @@ class NIPConfig(BaseModel): # NIP-20: Command Results (implemented partly) nip_20: bool = True - # NIP-22: End of Stored Events Notice - nip_22: bool = False + # NIP-22: Event created_at Limits + nip_22: tuple = (LOWER_TIMESTAMP_OFFSET_LIMIT, UPPER_TIMESTAMP_OFFSET_LIMIT) # NIP-33: End of Stored Events Notice nip_33: bool = False diff --git a/tests/relay/test_relay_dispatcher.py b/tests/relay/test_relay_dispatcher.py index 2ad2a56..9b0e399 100644 --- a/tests/relay/test_relay_dispatcher.py +++ b/tests/relay/test_relay_dispatcher.py @@ -1,3 +1,4 @@ +import time import uuid from collections import defaultdict @@ -10,6 +11,8 @@ from pyrelay.relay.bootstrap import get_uow_factory from pyrelay.relay.client_session import BaseClientSession from pyrelay.relay.dispatcher import RelayDispatcher +from pyrelay.relay.nip_config import nips_config +from pyrelay.relay.handlers.send_event_handler import is_creation_time_valid @pytest.fixture(scope="module") @@ -33,6 +36,18 @@ def get_dispatcher(): return dispatcher +async def _attempt_timestamp(event_builder, timestamps: list[int], should_save: bool): + dispatcher = get_dispatcher() + client_session = MockClientSession() + for timestamp in timestamps: + event = event_builder.create_event( + "", created_at=timestamp + ) + await dispatcher.handle(client_session, event) + for msg in client_session.calls["send_event"]: + assert msg.saved == should_save + + class TestRelayDispatcher: @pytest.mark.asyncio async def test_report_and_save_events(self, event_builder): @@ -123,7 +138,7 @@ async def test_subscribe_and_broadcast(self): ] assert calls == expected - + @pytest.mark.asyncio async def test_wrong_msg(self): dispatcher = get_dispatcher() @@ -144,3 +159,56 @@ async def test_event_3(self, event_builder): ] ) await dispatcher.handle(client_session, event) + + @pytest.mark.asyncio + async def test_legal_timestamp(self, event_builder): + nip_22_config = nips_config.nip_22 + if not nip_22_config: + return + current_time = time.time() + legal_timeframe_size = nip_22_config[1] - nip_22_config[0] + lower_bound_timestamp = current_time + nip_22_config[0] + amount = 5 + # do not check lower bound as it can fail due to runtime delay + legal_timestamps = [lower_bound_timestamp + legal_timeframe_size / amount * i for i in range(1, amount + 1)] + await _attempt_timestamp(event_builder=event_builder, timestamps=legal_timestamps, should_save=True) + + @pytest.mark.asyncio + async def test_illegal_timestamp(self, event_builder): + nip_22_config = nips_config.nip_22 + if not nip_22_config: + return + current_time = time.time() + illegal_timestamps = [ + int(current_time + nip_22_config[0] - 60 * 60 * 24 * 365), # a year before lower limit + int(current_time + nip_22_config[0] - 60), # a minute before lower limit + int(current_time + nip_22_config[1] + 60), # a minute after upper limit + int(current_time + nip_22_config[1] + 60 * 60 * 24 * 365) # a year after upper limit + ] + await _attempt_timestamp(event_builder=event_builder, timestamps=illegal_timestamps, should_save=False) + + def test_timestamp_validation(self): + good_timestamps_params = [ + (1500, 2000, (-600, 0)), + (1700, 2000, (-600, -200)), + (190000, 100000, (80000, 100000)), + (250000, 100000, (-600, 500000)), + (100000, 100000, (0, 1)), + (101, 100, (-600, 500000)), + (8000, 100000, None), + ] + bad_timestamps_params = [ + (1300, 2000, (-600, 0)), + (1900, 2000, (-600, -200)), + (150000, 100000, (80000, 100000)), + (9999999, 100000, (-600, 500000)), + (100001, 100000, (0, 1)), + (151, 100, (-600, 50)), + ] + for param_list, expected in [ + (good_timestamps_params, True), + (bad_timestamps_params, False) + ]: + for param_instance in param_list: + result = is_creation_time_valid(*param_instance) + assert result == expected