From 65a1b6b25bcd767ebe28a9e01d988de6989f13dd Mon Sep 17 00:00:00 2001 From: Loopii Date: Mon, 9 Nov 2026 15:45:52 +0200 Subject: [PATCH] P0: timing-safe secret, report IDOR hardening, OpenAPI sync --- openapi.yaml | 220 +++++++++++++++++++++++++++++++++++-- server/index.js | 25 ++++- server/p0Hardening.test.js | 85 ++++++++++++++ server/secretCompare.js | 22 ++++ server/siteConfig.js | 5 +- 5 files changed, 340 insertions(+), 17 deletions(-) create mode 100644 server/p0Hardening.test.js create mode 100644 server/secretCompare.js diff --git a/openapi.yaml b/openapi.yaml index db1d941..c124ceb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2,10 +2,116 @@ openapi: 3.0.3 info: title: ContractCheq API version: "1.0" + description: >- + ContractCheck SA API. Free preview is returned by analyze/report endpoints; + the full paid report is only returned by /api/unlock-analysis after a + verified Paystack receipt (reference + matching email) has spent a credit. + GET /api/report/{id} returns the free preview unauthenticated; with + x-payment-reference (+ optional x-receipt-email) it returns the full report + only for a grant that has already unlocked that report, otherwise 404. paths: - /api/upload: + /api/health: + get: + summary: Service and storage health + responses: + "200": + description: Health status JSON + /api/health/storage: + get: + summary: On-demand storage write/read/delete probe (rate limited) + responses: + "200": + description: Storage writable + "503": + description: Storage not writable + /api/config: + get: + summary: Public client configuration (prices, paystackPublicKey, limits) + responses: + "200": + description: Config JSON + /api/demo/{key}: + get: + summary: Run a built-in demo sample through the rule engine + parameters: + - name: key + in: path + required: true + schema: + type: string + responses: + "200": + description: Full demo analysis + "404": + description: Example not found + /api/events: post: - summary: Upload a contract PDF or text + summary: Fire-and-forget telemetry event + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + props: + type: object + responses: + "200": + description: Accepted + "400": + description: name required + /api/credits: + get: + summary: Remaining credits for a payment receipt + parameters: + - name: x-payment-reference + in: header + required: true + schema: + type: string + responses: + "200": + description: Remaining credits + "401": + description: Payment receipt required + "404": + description: Receipt not found + /api/verify-payment: + post: + summary: Verify a Paystack transaction and create a report-credit grant + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [reference, email] + properties: + reference: + type: string + pattern: '^CC-[A-Za-z0-9-]{10,100}$' + email: + type: string + product: + type: string + enum: [single, pack3, pack10] + responses: + "200": + description: Verified grant JSON + "400": + description: Invalid payment reference + "402": + description: Payment not successful / product mismatch + "403": + description: Receipt email mismatch + "503": + description: Storage unavailable + /api/analyze: + post: + summary: Upload a contract file (PDF/image/txt) and return a free risk preview requestBody: required: true content: @@ -19,29 +125,119 @@ paths: jurisdiction: type: string enum: [ZA, UK] + perspective: + type: string + enrich: + type: string + responses: + "200": + description: Free preview JSON (full report locked) + "400": + description: No file / validation error + "413": + description: File too large + "422": + description: Could not extract enough text + /api/analyze-text: + post: + summary: Analyze pasted contract text and return a free risk preview + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + fileName: + type: string + jurisdiction: + type: string + enum: [ZA, UK] + perspective: + type: string responses: "200": - description: Risk preview JSON - content: - application/json: - schema: - type: object + description: Free preview JSON "400": description: Validation error - /api/report: + "413": + description: Text too large + /api/report/{id}: + get: + summary: Resume a saved scan. Free preview only unless an unlocking grant credential is supplied. + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: x-payment-reference + in: header + required: false + description: Payment receipt reference; full report only if this grant already unlocked this report. + schema: + type: string + - name: x-receipt-email + in: header + required: false + schema: + type: string + responses: + "200": + description: Free preview, or full report for an unlocking grant + "400": + description: Missing/invalid report id + "404": + description: Report not found, expired, or not unlocked by the supplied grant (no existence leak) + /api/unlock-analysis: post: - summary: Unlock full report (requires payment token) + summary: Spend a verified credit to unlock the full report requestBody: required: true content: application/json: schema: type: object + required: [analysisId] properties: - token: + analysisId: + type: string + reference: + type: string + grantId: type: string + email: + type: string + reopenOnly: + type: boolean responses: "200": - description: Full PDF report + description: Full paid report JSON + "400": + description: Missing analysisId "402": - description: Payment required + description: Payment receipt required / no credits remaining + "403": + description: Invalid receipt/email, or reopenOnly for a report this receipt did not unlock + "404": + description: Analysis expired + /api/cleanup: + get: + summary: Delete expired analyses (Bearer CRON_SECRET, timing-safe compared) + security: + - bearerAuth: [] + responses: + "200": + description: Cleanup complete + "401": + description: Unauthorized +components: + securitySchemes: + bearerAuth: + type: apiKey + in: header + name: Authorization + description: 'Bearer ' diff --git a/server/index.js b/server/index.js index 2c37466..db9f544 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ const { validatePerspective } = require('./reportContext'); const DEMO_SAMPLES = require('./demoSamples'); const store = require('./store'); const { SITE_ORIGIN, canonicalHost } = require('./siteConfig'); +const { isBearerAuthorized } = require('./secretCompare'); const PORT = Number(process.env.PORT) || 3000; const MAX_FILE_MB = process.env.VERCEL ? 4 : (Number(process.env.MAX_FILE_MB) || 12); @@ -536,9 +537,27 @@ app.post('/api/analyze-text', uploadLimiter, async (req,res,next)=> { app.get('/api/report/:id', async (req,res,next)=> { try { const id = String(req.params.id || ''); - if (!id || id.length > 200) return res.status(400).json({ error: 'Missing report id' }); + if (!id || id.length > 200 || /[/\\]/.test(id) || id.includes('..')) return res.status(400).json({ error: 'Missing report id' }); const stored = await store.getAnalysis(id); if (!stored?.full) return res.status(404).json({ error: 'This preview has expired. Scan again to get a new preview.' }); + // IDOR hardening (P0-3): this endpoint must never disclose a full/paid report + // to an unauthenticated caller. The frontend resume flow (public/index.html) + // calls it with NO credentials and relies on the free preview only, so full + // auth here would break the demo/resume flow. Minimal check consistent with + // the existing grant model (/api/credits + /api/unlock-analysis): + // - no credential -> free preview only (freeViewOf strips paid fields) + // - credential supplied (x-payment-reference, optional x-receipt-email) + // -> full report only if the grant exists, email matches when supplied, + // and the grant has already unlocked this analysisId; otherwise the + // same generic 404 as for a missing report (no existence leak). + const reference = String(req.headers['x-payment-reference'] || ''); + if (reference) { + const grant = await store.getGrant(reference); + const email = String(req.headers['x-receipt-email'] || '').toLowerCase().trim(); + const unlocked = grant && (!email || String(grant.email || '').toLowerCase().trim() === email) && Array.isArray(grant.unlocked) && grant.unlocked.includes(id); + if (!unlocked) return res.status(404).json({ error: 'This preview has expired. Scan again to get a new preview.' }); + return res.json({ ...stored.full, isPaid: true }); + } res.json(freeViewOf(stored.full)); } catch (e) { next(e); } }); @@ -562,7 +581,7 @@ app.post('/api/unlock-analysis', payLimiter, async (req,res,next)=> { }); app.get('/api/cleanup', async (req,res,next)=> { - if (!process.env.CRON_SECRET || req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) return res.status(401).end(); + if (!isBearerAuthorized(req.headers.authorization, process.env.CRON_SECRET)) return res.status(401).end(); try { await store.cleanupExpired(); res.json({ ok: true }); } catch (e) { next(e); } }); @@ -594,4 +613,4 @@ if (require.main === module) app.listen(PORT, () => { }); module.exports = app; -module.exports.__internal = { redactSensitiveText, externalAiEnabled, freeViewOf, extractFromImage }; +module.exports.__internal = { redactSensitiveText, externalAiEnabled, freeViewOf, extractFromImage, isBearerAuthorized }; diff --git a/server/p0Hardening.test.js b/server/p0Hardening.test.js new file mode 100644 index 0000000..a26e350 --- /dev/null +++ b/server/p0Hardening.test.js @@ -0,0 +1,85 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { analyseContract } = require('./riskEngine'); + +test('timing-safe Bearer check handles length mismatch and unset secret', async () => { + const { isBearerAuthorized, timingSafeEqualString } = require('./secretCompare'); + assert.equal(timingSafeEqualString('abc', 'abc'), true); + assert.equal(timingSafeEqualString('abc', 'abd'), false); + assert.equal(timingSafeEqualString('abc', 'abcd'), false); // must not throw + assert.equal(timingSafeEqualString('', ''), true); + assert.equal(isBearerAuthorized('Bearer s3cret', 's3cret'), true); + assert.equal(isBearerAuthorized('Bearer wrong', 's3cret'), false); + assert.equal(isBearerAuthorized('Bearer s3cret-longer', 's3cret'), false); + assert.equal(isBearerAuthorized('Bearer s3cret', ''), false); + assert.equal(isBearerAuthorized('Bearer s3cret', undefined), false); + assert.equal(isBearerAuthorized(undefined, 's3cret'), false); + assert.equal(isBearerAuthorized('Basic s3cret', 's3cret'), false); +}); + +test('GET /api/report/:id never leaks full report without an unlocking grant', async () => { + for (const k of ['VERCEL', 'BLOB_STORE_ID', 'BLOB_READ_WRITE_TOKEN', 'SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_TABLE']) delete process.env[k]; + process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-p0-')); + const store = require('./store'); + const app = require('./index'); + const server = app.listen(0); + await new Promise(r => server.once('listening', r)); + const base = 'http://127.0.0.1:' + server.address().port; + try { + const analysis = analyseContract('EMPLOYMENT AGREEMENT. The employee shall work 55 hours per week. ' + 'Clause '.repeat(40), 'scan.txt'); + analysis._id = 'p0-report-1'; + analysis.isPaid = false; + await store.saveAnalysis('p0-report-1', analysis); + + // Unauthenticated: free preview only, paid fields stripped. + const free = await fetch(base + '/api/report/p0-report-1'); + assert.equal(free.status, 200); + const freeBody = await free.json(); + assert.equal(freeBody.freePreview, true); + assert.equal(freeBody.isPaid, false); + assert.ok(!('plainLanguageGuide' in freeBody) || !freeBody.plainLanguageGuide || freeBody.issuesLocked); + + // Unknown id and credentialed-but-not-unlocked are indistinguishable (404, same message). + const missing = await fetch(base + '/api/report/does-not-exist'); + assert.equal(missing.status, 404); + const missingBody = await missing.json(); + const bogus = await fetch(base + '/api/report/p0-report-1', { headers: { 'x-payment-reference': 'CC-NOTAREAL-123456' } }); + assert.equal(bogus.status, 404); + assert.deepEqual(await bogus.json(), missingBody); + + // Grant that has NOT unlocked this id: still 404, no leak. + await store.saveGrant('CC-TESTGRANT-123456', { email: 'buyer@example.test', product: 'single', amount: 1900, credits: 1 }); + const notUnlocked = await fetch(base + '/api/report/p0-report-1', { headers: { 'x-payment-reference': 'CC-TESTGRANT-123456' } }); + assert.equal(notUnlocked.status, 404); + + // After unlock via the existing flow, the same credential returns the full report. + const unlock = await fetch(base + '/api/unlock-analysis', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ analysisId: 'p0-report-1', reference: 'CC-TESTGRANT-123456', email: 'buyer@example.test' }), + }); + assert.equal(unlock.status, 200); + const full = await fetch(base + '/api/report/p0-report-1', { + headers: { 'x-payment-reference': 'CC-TESTGRANT-123456', 'x-receipt-email': 'buyer@example.test' }, + }); + assert.equal(full.status, 200); + const fullBody = await full.json(); + assert.equal(fullBody.isPaid, true); + assert.equal(fullBody._id, 'p0-report-1'); + + // Wrong email for the same reference: 404, no leak. + const wrongEmail = await fetch(base + '/api/report/p0-report-1', { + headers: { 'x-payment-reference': 'CC-TESTGRANT-123456', 'x-receipt-email': 'other@example.test' }, + }); + assert.equal(wrongEmail.status, 404); + + // Path-traversal style ids are rejected. + const trav = await fetch(base + '/api/report/' + encodeURIComponent('../secret')); + assert.equal(trav.status, 400); + } finally { + server.close(); + fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); + } +}); diff --git a/server/secretCompare.js b/server/secretCompare.js new file mode 100644 index 0000000..2c43b49 --- /dev/null +++ b/server/secretCompare.js @@ -0,0 +1,22 @@ +const crypto = require('node:crypto'); + +/** + * Constant-time string comparison that is safe for differing lengths. + * Hashing both sides to fixed-length digests means timingSafeEqual never + * throws on length mismatch and comparison time does not reveal length. + */ +function timingSafeEqualString(a, b) { + const left = crypto.createHash('sha256').update(String(a)).digest(); + const right = crypto.createHash('sha256').update(String(b)).digest(); + return crypto.timingSafeEqual(left, right); +} + +/** True only when `secret` is set and the Authorization header is `Bearer `. */ +function isBearerAuthorized(authorization, secret) { + if (!secret || typeof secret !== 'string') return false; + const header = String(authorization || ''); + if (!header.startsWith('Bearer ')) return false; + return timingSafeEqualString(header.slice('Bearer '.length), secret); +} + +module.exports = { timingSafeEqualString, isBearerAuthorized }; diff --git a/server/siteConfig.js b/server/siteConfig.js index be16d25..c6f428a 100644 --- a/server/siteConfig.js +++ b/server/siteConfig.js @@ -7,8 +7,9 @@ function canonicalHost(req, res, next) { if (!process.env.VERCEL || String(req.headers.host || '').toLowerCase() === SITE_HOST) return next(); // Vercel Cron can call a generated deployment URL. This is a private // maintenance endpoint, never a second public entry point to the product. - if (req.method === 'GET' && req.path === '/api/cleanup' && process.env.CRON_SECRET && - req.headers.authorization === `Bearer ${process.env.CRON_SECRET}`) return next(); + const { isBearerAuthorized } = require('./secretCompare'); + if (req.method === 'GET' && req.path === '/api/cleanup' && + isBearerAuthorized(req.headers.authorization, process.env.CRON_SECRET)) return next(); return res.status(404).set({'Cache-Control':'no-store','X-Robots-Tag':'noindex'}).end(); }