openvibers/openvibe.coupons Commits

Reports: the submitter of a code (or of evidence for it) cannot report on it

OP OpenVibers committed 23 Sept 2026 · 870c3893b4 · 5 files, +36 −7

One account could submit a made-up code and then report it worked through the API, the site
form or an extension token, which made it "Reported working" (67%) for everyone. Such reports
are now refused with 403 report.own_submission; tests that had submitters report their own codes
now use another person.

Changes

ddiff --git a/README.md b/README.md
iindex 6c39260..34dcb35 100644
---- a/README.md
++++ b/README.md
@@@ -116,6 +116,8 @@ The sweep records the decay: status history, `coupons.confidence.changed`, and a
- Staff and services with `coupons.status.update` can only disable a code, mark it expired, or
return it to active. Returning it recomputes the status from reports and time.
- Reports marked `X-OV-Origin: ai` are refused.
++ - A person who submitted a code, or added evidence for it, can't report on it (403
++ `report.own_submission`). Otherwise one account could submit a made-up code and mark it working.
- AI-extracted codes (`coupons.extract_coupon` output) are held for staff review. They start
`unknown` and carry an on-page disclosure.
ddiff --git a/server/domain/reports.js b/server/domain/reports.js
iindex 9e9b6f2..fb49bd2 100644
---- a/server/domain/reports.js
++++ b/server/domain/reports.js
@@@ -5,8 +5,9 @@
*
* Who may report: a signed-in person (site form or a Bearer Network token), an extension install
* token with the coupons.report scope (it belongs to the person who connected it), or a service
-- * with coupons.report.create acting for a person (X-OV-Subject). Never anonymously, and never as
-- * AI output (X-OV-Origin: ai is refused): a model cannot report that a code works.
++ * with coupons.report.create acting for a person (X-OV-Subject). Never anonymously, never as
++ * AI output (X-OV-Origin: ai is refused): a model cannot report that a code works, and never by
++ * a person who submitted the code or evidence for it (403 report.own_submission).
*
* Deduplicated: one row per (reporter, coupon, UTC day). A second report the same day with the
* same outcome changes nothing; with the other outcome it replaces that day's row (a correction,
@@@ -37,6 +38,7 @@ function createReports({ store, config, coupons, merchants, publication }) {
VALUES (@coupon_id, @reporter_key, @channel, @install_id, @day, @outcome, @reason, @now, @now)`),
correct: db.prepare('UPDATE coupon_validation_reports SET outcome = @outcome, reason = @reason, channel = @channel, install_id = @install_id, updated_at = @now WHERE id = @id'),
since: db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE reporter_key = ? AND created_at > ?'),
++ submittedBy: db.prepare('SELECT 1 FROM coupon_sources WHERE coupon_id = ? AND submitted_by = ? LIMIT 1'),
stamp: db.prepare(`UPDATE coupons SET last_report_at = @now,
last_worked_at = CASE WHEN @outcome = 'worked' THEN @now ELSE last_worked_at END,
last_failed_at = CASE WHEN @outcome = 'failed' THEN @now ELSE last_failed_at END
@@@ -69,6 +71,11 @@ function createReports({ store, config, coupons, merchants, publication }) {
const now = store.now();
const isActive = m && m.status === 'active' && c.review_state === 'published' && c.status !== 'expired' && c.status !== 'disabled' && (c.expires_at == null || c.expires_at > now);
if (!isActive) throw new ApiError(409, 'coupon.not_active', 'this code is not in active results (expired, disabled or not published)');
++ // The submitter is not a reporter: otherwise one account could submit a made-up code and
++ // make it "reported working" with its own report.
++ if (c.created_by === who.subject || q.submittedBy.get(c.id, who.subject)) {
++ throw new ApiError(403, 'report.own_submission', 'you submitted this code; its status comes from other people\'s reports');
++ }
const key = reporterKey(who.subject);
const day = new Date(now).toISOString().slice(0, 10);
ddiff --git a/test/extension-privacy.test.js b/test/extension-privacy.test.js
iindex dee9d12..20b01ab 100644
---- a/test/extension-privacy.test.js
++++ b/test/extension-privacy.test.js
@@@ -16,7 +16,8 @@ const DAY = 24 * 3600 * 1000;
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
const shop = await t.merchant({ name: 'Paper Lantern', host: 'paperlantern.store' });
-- const code = (await t.submit(alice, { host: 'paperlantern.store', code: 'GLOW', title: 'Ten off lamps', evidence_url: 'https://paperlantern.store/sale' })).json().coupon;
++ const submitter = t.network.addUser('sam');
++ const code = (await t.submit(submitter, { host: 'paperlantern.store', code: 'GLOW', title: 'Ten off lamps', evidence_url: 'https://paperlantern.store/sale' })).json().coupon;
await t.get(`/api/v1/coupons/${code.id}/report`, { as: t.network.userToken(alice), json: { outcome: 'worked' } });
let aliceToken;
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 0946314..16265a1 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -35,14 +35,17 @@ const { boot, check, done } = require('./helpers/boot');
});
await check('report and watch with plain forms; the watch list is private', async () => {
-- const r = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/m/${shop.slug}` } });
++ const own = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/m/${shop.slug}` } });
++ assert.strictEqual(own.status, 403, 'the submitter cannot report on her own code');
++ const bob = t.network.addUser('bob');
++ const r = await t.get(`/c/${codeId}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: `/m/${shop.slug}` } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);
-- const after = await t.get(`/m/${shop.slug}?done=reported`, { as: alice });
++ const after = await t.get(`/m/${shop.slug}?done=reported`, { as: bob });
assert.match(after.text, /your report was recorded/);
assert.match(after.text, /Reported working/);
assert.match(after.text, /Confidence 67%/);
-- const offsite = await t.get(`/c/${codeId}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: 'https://evil.example-shop.com/' } });
++ const offsite = await t.get(`/c/${codeId}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: 'https://evil.example-shop.com/' } });
assert.strictEqual(offsite.headers.get('location'), `/c/${codeId}?done=report_same`, 'no open redirect');
const w = await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'watch' } });
assert.strictEqual(w.status, 303);
ddiff --git a/test/reports.test.js b/test/reports.test.js
iindex 4bf63c9..dbb50dd 100644
---- a/test/reports.test.js
++++ b/test/reports.test.js
@@@ -58,6 +58,22 @@ const DAY = 24 * HOUR;
assert.strictEqual(t.events('coupons.report.created').length, 1, 'one report, one event');
});
++ await check('the submitter (or anyone who added evidence) cannot report on the code: one account cannot make its own code "working"', async () => {
++ const fake = await mk('MADEUP50');
++ const api = await report(alice, fake.id, { outcome: 'worked' });
++ assert.strictEqual(api.status, 403);
++ assert.strictEqual(api.json().code, 'report.own_submission');
++ const form = await t.get(`/c/${fake.id}/report`, { as: alice, form: { csrf: t.csrf(alice), outcome: 'worked', back: `/c/${fake.id}` } });
++ assert.strictEqual(form.status, 403);
++ const token = await t.connect(alice, { label: 'alice laptop' });
++ assert.strictEqual((await report(token, fake.id, { outcome: 'worked' })).status, 403);
++ const hank = t.network.addUser('hank');
++ assert.strictEqual((await t.submit(hank, { host: 'bluekettle.shop', code: 'MADEUP50', title: 'Code MADEUP50', evidence_url: 'https://bluekettle.shop/deals' })).status, 200, 'duplicate: evidence added');
++ assert.strictEqual((await report(hank, fake.id, { outcome: 'worked' })).status, 403, 'evidence submitters are submitters too');
++ assert.strictEqual(t.ctx.coupons.get(fake.id).status, 'unknown');
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE coupon_id = ?').get(fake.id).n, 0);
++ });
++
await check('the next day is a new report, but only the latest per person counts', async () => {
t.clock.advance(DAY);
const r = await report(bob, code.id, { outcome: 'worked' });
@@@ -73,7 +89,7 @@ const DAY = 24 * HOUR;
});
await check('the response and the events never name a reporter; report times are published to the hour', async () => {
-- const r = await report(alice, code.id, { outcome: 'worked' });
++ const r = await report(t.network.addUser('gina'), code.id, { outcome: 'worked' });
const body = r.text;
assert.doesNotMatch(body, /usr_[0-9A-Z]{26}/);
assert.doesNotMatch(body, /reporter|install_id|cpi_/);