Skip to content

Commit 39158c9

Browse files
authored
Merge pull request #42 from devallibus/development
feat: spam protection and input validation for reviews
2 parents c78a646 + e8a6aad commit 39158c9

4 files changed

Lines changed: 210 additions & 10 deletions

File tree

apps/web/src/components/ReviewsSection.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,14 @@ export default function ReviewsSection(props: ReviewsSectionProps) {
4242
const [newComment, setNewComment] = createSignal('')
4343
const [submitting, setSubmitting] = createSignal(false)
4444
const [submitMessage, setSubmitMessage] = createSignal('')
45+
const [submitError, setSubmitError] = createSignal(false)
46+
const [cooldown, setCooldown] = createSignal(false)
4547

4648
const handleSubmit = async () => {
47-
if (newRating() === 0) return
49+
if (newRating() === 0 || submitting() || cooldown()) return
4850
setSubmitting(true)
4951
setSubmitMessage('')
52+
setSubmitError(false)
5053

5154
try {
5255
await submit({
@@ -61,10 +64,15 @@ export default function ReviewsSection(props: ReviewsSectionProps) {
6164
setNewRating(0)
6265
setNewComment('')
6366

67+
// Cooldown to prevent rapid re-submission
68+
setCooldown(true)
69+
setTimeout(() => setCooldown(false), 5000)
70+
6471
const updated = await fetchReviews({ data: { shaderName: props.shaderName } })
6572
setReviews(updated.reviews)
6673
setStats(updated.stats)
6774
} catch (e) {
75+
setSubmitError(true)
6876
setSubmitMessage(e instanceof Error ? e.message : 'Failed to submit')
6977
} finally {
7078
setSubmitting(false)
@@ -122,13 +130,15 @@ export default function ReviewsSection(props: ReviewsSectionProps) {
122130
<button
123131
type="button"
124132
class="rounded-full bg-accent px-4 py-1.5 text-xs font-semibold text-surface-primary transition hover:bg-accent/80 disabled:opacity-50"
125-
disabled={newRating() === 0 || submitting()}
133+
disabled={newRating() === 0 || submitting() || cooldown()}
126134
onClick={() => void handleSubmit()}
127135
>
128-
{submitting() ? 'Submitting...' : 'Submit'}
136+
{submitting() ? 'Submitting...' : cooldown() ? 'Submitted' : 'Submit'}
129137
</button>
130138
<Show when={submitMessage()}>
131-
<span class="text-xs text-text-muted">{submitMessage()}</span>
139+
<span class={`text-xs ${submitError() ? 'text-red-400' : 'text-text-muted'}`}>
140+
{submitMessage()}
141+
</span>
132142
</Show>
133143
</div>
134144
</div>

apps/web/src/lib/server/reviews-db.test.node.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import assert from 'node:assert/strict'
2-
import {
2+
import { mkdtempSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { tmpdir } from 'node:os'
5+
6+
// Isolate test DB in a temp directory so runs are idempotent
7+
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'reviews-test-'))
8+
9+
const {
310
addReview,
411
getReviewsForShader,
512
getAverageRating,
613
getAllShaderRatings,
7-
} from './reviews-db.ts'
14+
} = await import('./reviews-db.ts')
815

916
function runTest(name: string, callback: () => void | Promise<void>) {
1017
const result = callback()
@@ -105,4 +112,72 @@ runTest('addReview handles optional parameters', () => {
105112
assert.equal(reviews[0]!.userID, 'user-456')
106113
})
107114

115+
runTest('rejects invalid rating (too high)', () => {
116+
assert.throws(() => addReview(`${testShader}-bad1`, 6), /Rating must be an integer/)
117+
})
118+
119+
runTest('rejects invalid rating (zero)', () => {
120+
assert.throws(() => addReview(`${testShader}-bad2`, 0), /Rating must be an integer/)
121+
})
122+
123+
runTest('rejects invalid rating (float)', () => {
124+
assert.throws(() => addReview(`${testShader}-bad3`, 3.5), /Rating must be an integer/)
125+
})
126+
127+
runTest('sanitizes unknown source to web', () => {
128+
const shader = `${testShader}-badsource`
129+
addReview(shader, 3, null, 'evil-source')
130+
const { reviews } = getReviewsForShader(shader)
131+
assert.equal(reviews[0]!.source, 'web')
132+
})
133+
134+
runTest('truncates long comments', () => {
135+
const shader = `${testShader}-longcomment`
136+
const longComment = 'x'.repeat(5000)
137+
addReview(shader, 4, longComment)
138+
const { reviews } = getReviewsForShader(shader)
139+
assert.equal(reviews[0]!.comment!.length, 2000)
140+
})
141+
142+
runTest('duplicate review from same reviewer token is rejected', () => {
143+
const shader = `${testShader}-dupe`
144+
addReview(shader, 4, null, 'web', null, null, '192.168.1.100', 'reviewer-a')
145+
assert.throws(
146+
() => addReview(shader, 5, null, 'web', null, null, '198.51.100.2', 'reviewer-a'),
147+
/already reviewed this shader/,
148+
)
149+
})
150+
151+
runTest('allows same shader from different reviewer tokens on same IP', () => {
152+
const shader = `${testShader}-shared-ip`
153+
const ip = '10.0.0.1'
154+
addReview(shader, 4, null, 'web', null, null, ip, 'reviewer-a')
155+
addReview(shader, 5, null, 'web', null, null, ip, 'reviewer-b')
156+
})
157+
158+
runTest('allows different shaders from same IP', () => {
159+
const ip = '10.0.0.1'
160+
addReview(`${testShader}-diff1`, 4, null, 'web', null, null, ip)
161+
addReview(`${testShader}-diff2`, 4, null, 'web', null, null, ip)
162+
// Should not throw — different shaders
163+
})
164+
165+
runTest('reviews without IP bypass rate limiting', () => {
166+
const shader = `${testShader}-noip`
167+
addReview(shader, 4, null, 'web', null, null, null)
168+
addReview(shader, 5, null, 'web', null, null, null)
169+
// Should not throw — no IP to track
170+
})
171+
172+
runTest('rate limit rejects 6th review from same IP within window', () => {
173+
const ip = '172.16.0.1'
174+
for (let i = 1; i <= 5; i++) {
175+
addReview(`${testShader}-rl-${i}`, 3, null, 'web', null, null, ip, `reviewer-${i}`)
176+
}
177+
assert.throws(
178+
() => addReview(`${testShader}-rl-6`, 3, null, 'web', null, null, ip, 'reviewer-6'),
179+
/Too many reviews submitted/,
180+
)
181+
})
182+
108183
console.log('reviews-db tests passed')

apps/web/src/lib/server/reviews-db.ts

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,36 @@ db.exec(`
1717
source TEXT NOT NULL DEFAULT 'web',
1818
agent_context TEXT,
1919
user_id TEXT,
20+
client_ip TEXT,
21+
reviewer_token_hash TEXT,
2022
created_at TEXT NOT NULL DEFAULT (datetime('now'))
2123
);
2224
CREATE INDEX IF NOT EXISTS idx_reviews_shader ON reviews(shader_name);
2325
`)
2426

27+
// Migration: add client_ip column to tables created before this column existed
28+
try {
29+
db.exec(`ALTER TABLE reviews ADD COLUMN client_ip TEXT`)
30+
} catch {
31+
// Column already exists
32+
}
33+
34+
try {
35+
db.exec(`ALTER TABLE reviews ADD COLUMN reviewer_token_hash TEXT`)
36+
} catch {
37+
// Column already exists
38+
}
39+
40+
db.exec(`CREATE INDEX IF NOT EXISTS idx_reviews_ip_time ON reviews(client_ip, created_at)`)
41+
db.exec(
42+
`CREATE INDEX IF NOT EXISTS idx_reviews_reviewer_shader_time
43+
ON reviews(reviewer_token_hash, shader_name, created_at)`,
44+
)
45+
46+
const MAX_COMMENT_LENGTH = 2000
47+
const RATE_LIMIT_WINDOW_MINUTES = 10
48+
const RATE_LIMIT_MAX_REVIEWS = 5
49+
2550
export type Review = {
2651
id: number
2752
shaderName: string
@@ -45,18 +70,72 @@ export function addReview(
4570
source = 'web',
4671
agentContext?: string | null,
4772
userId?: string | null,
73+
clientIp?: string | null,
74+
reviewerTokenHash?: string | null,
4875
): number {
76+
// Validate rating is an integer 1-5
77+
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
78+
throw new Error('Rating must be an integer between 1 and 5')
79+
}
80+
81+
// Validate and truncate comment
82+
const sanitizedComment = comment ? comment.slice(0, MAX_COMMENT_LENGTH).trim() || null : null
83+
84+
// Validate source
85+
const allowedSources = ['web', 'mcp', 'cli']
86+
const sanitizedSource = allowedSources.includes(source) ? source : 'web'
87+
88+
// Rate limit: max N reviews per IP within the window
89+
if (clientIp) {
90+
const recentCount = db
91+
.prepare(
92+
`SELECT COUNT(*) AS count FROM reviews
93+
WHERE client_ip = ? AND created_at > datetime('now', ?)`,
94+
)
95+
.get(clientIp, `-${RATE_LIMIT_WINDOW_MINUTES} minutes`) as { count: number }
96+
97+
if (recentCount.count >= RATE_LIMIT_MAX_REVIEWS) {
98+
throw new Error('Too many reviews submitted. Please try again later.')
99+
}
100+
101+
}
102+
103+
if (reviewerTokenHash) {
104+
const duplicate = db
105+
.prepare(
106+
`SELECT id FROM reviews
107+
WHERE reviewer_token_hash = ?
108+
AND shader_name = ?
109+
AND created_at > datetime('now', '-24 hours')`,
110+
)
111+
.get(reviewerTokenHash, shaderName) as { id: number } | undefined
112+
113+
if (duplicate) {
114+
throw new Error('You already reviewed this shader recently')
115+
}
116+
}
117+
49118
const stmt = db.prepare(
50-
`INSERT INTO reviews (shader_name, rating, comment, source, agent_context, user_id)
51-
VALUES (?, ?, ?, ?, ?, ?)`,
119+
`INSERT INTO reviews (
120+
shader_name,
121+
rating,
122+
comment,
123+
source,
124+
agent_context,
125+
user_id,
126+
client_ip,
127+
reviewer_token_hash
128+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
52129
)
53130
const result = stmt.run(
54131
shaderName,
55132
rating,
56-
comment ?? null,
57-
source,
133+
sanitizedComment,
134+
sanitizedSource,
58135
agentContext ?? null,
59136
userId ?? null,
137+
clientIp ?? null,
138+
reviewerTokenHash ?? null,
60139
)
61140
return Number(result.lastInsertRowid)
62141
}

apps/web/src/routes/api/-reviews.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { randomBytes, createHash } from 'node:crypto'
12
import { createServerFn } from '@tanstack/solid-start'
3+
import { getCookie, getRequestIP, getRequestProtocol, setCookie } from '@tanstack/solid-start/server'
24

35
type SubmitReviewInput = {
46
shaderName: string
@@ -13,24 +15,58 @@ type GetReviewsInput = {
1315
shaderName: string
1416
}
1517

18+
const REVIEWER_COOKIE_NAME = 'shaderbase-reviewer'
19+
const REVIEWER_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365
20+
21+
function getClientIp(): string | null {
22+
return getRequestIP({ xForwardedFor: true }) ?? null
23+
}
24+
25+
function hashReviewerToken(token: string): string {
26+
return createHash('sha256').update(token).digest('hex')
27+
}
28+
29+
function getOrCreateReviewerTokenHash(): string | null {
30+
let reviewerToken = getCookie(REVIEWER_COOKIE_NAME)
31+
32+
if (!reviewerToken) {
33+
reviewerToken = randomBytes(32).toString('hex')
34+
setCookie(REVIEWER_COOKIE_NAME, reviewerToken, {
35+
httpOnly: true,
36+
maxAge: REVIEWER_COOKIE_MAX_AGE_SECONDS,
37+
path: '/',
38+
sameSite: 'lax',
39+
secure: getRequestProtocol({ xForwardedProto: true }) === 'https',
40+
})
41+
}
42+
43+
return reviewerToken ? hashReviewerToken(reviewerToken) : null
44+
}
45+
1646
export const submitReview = createServerFn({ method: 'POST' })
1747
.inputValidator((input: SubmitReviewInput) => input)
1848
.handler(async ({ data }) => {
1949
const { addReview } = await import('../../lib/server/reviews-db')
2050
const { listShadersFromSource } = await import('../../lib/server/shader-source')
2151

52+
// Validate shader exists
2253
const shaders = await listShadersFromSource()
2354
if (!shaders.some((s) => s.name === data.shaderName)) {
2455
throw new Error(`Shader "${data.shaderName}" not found`)
2556
}
2657

58+
const clientIp = getClientIp()
59+
const reviewerTokenHash = getOrCreateReviewerTokenHash()
60+
2761
const reviewId = addReview(
2862
data.shaderName,
2963
data.rating,
3064
data.comment ?? null,
3165
data.source ?? 'web',
3266
data.agentContext ? JSON.stringify(data.agentContext) : null,
3367
data.userId ?? null,
68+
clientIp,
69+
reviewerTokenHash,
3470
)
3571

3672
return { ok: true as const, reviewId }

0 commit comments

Comments
 (0)