Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-event-expiration-validation.md
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 6 additions & 1 deletion src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -253,7 +258,7 @@ export const getEventExpiration = (event: Event): number | undefined => {

const expirationTime = Number(rawExpirationTime)

if (Number.isSafeInteger(expirationTime) && Math.log10(expirationTime) < 10) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! The Math.log10(0) fix is a genuine improvement.

if (Number.isSafeInteger(expirationTime) && expirationTime > 0 && expirationTime <= MAX_EXPIRATION_TIME) {
return expirationTime
}
}
Expand Down
25 changes: 25 additions & 0 deletions test/unit/utils/event.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading