diff --git a/.changeset/fix-event-expiration-validation.md b/.changeset/fix-event-expiration-validation.md new file mode 100644 index 00000000..4744f45c --- /dev/null +++ b/.changeset/fix-event-expiration-validation.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix: reject expiration timestamp 0 and millisecond-scale values, and accept safe-integer second-based timestamps up to the Postgres int4 max (2038-01-19T03:14:07Z) in getEventExpiration() diff --git a/src/utils/event.ts b/src/utils/event.ts index 78b012b0..7de72d40 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -245,6 +245,11 @@ export const isExpiredEvent = (event: Event): boolean => { return expirationTime <= now } +// Postgres int4 max (2038-01-19T03:14:07Z). events.expires_at is a signed 32-bit +// integer column — Knex's .unsigned() is a no-op on Postgres — so anything beyond +// this would fail the INSERT and get silently dropped instead of stored. +const MAX_EXPIRATION_TIME = 2147483647 + export const getEventExpiration = (event: Event): number | undefined => { const [, rawExpirationTime] = event.tags.find((tag) => tag.length >= 2 && tag[0] === EventTags.Expiration) ?? [] if (!rawExpirationTime) { @@ -253,7 +258,7 @@ export const getEventExpiration = (event: Event): number | undefined => { const expirationTime = Number(rawExpirationTime) - if (Number.isSafeInteger(expirationTime) && Math.log10(expirationTime) < 10) { + if (Number.isSafeInteger(expirationTime) && expirationTime > 0 && expirationTime <= MAX_EXPIRATION_TIME) { return expirationTime } } diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index 53cc0f6c..39d26c33 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -691,6 +691,31 @@ describe('NIP-40', () => { event.tags = [['expiration', 'a']] expect(getEventExpiration(event)).to.be.undefined }) + + it('returns false if expiration is 0', () => { + event.tags = [['expiration', '0']] + expect(getEventExpiration(event)).to.be.undefined + }) + + it('returns false if expiration exceeds the Postgres int4 column max', () => { + event.tags = [['expiration', '10000000000']] + expect(getEventExpiration(event)).to.be.undefined + }) + + it('returns false if expiration is the maximum representable Unix seconds timestamp', () => { + event.tags = [['expiration', '253402300799']] + expect(getEventExpiration(event)).to.be.undefined + }) + + it('returns true if expiration is the maximum value the expires_at column can store', () => { + event.tags = [['expiration', '2147483647']] + expect(getEventExpiration(event)).to.equal(2147483647) + }) + + it('returns false if expiration looks like a millisecond-scale timestamp', () => { + event.tags = [['expiration', '1700000000000']] + expect(getEventExpiration(event)).to.be.undefined + }) }) describe('isExpiredEvent', () => {