Root cause
Other / insufficient public mechanism detail introduced
Enhance Taysell security and usability
AI-assisted change: an AI coding tool
How AI contributed: Direct introductionPublished Jun 27, 2025 in
tailot/tayloredAll 2 findings in this repository
Hi team, I was looking at the recent fix and you limited the exploitability of race conditions but unfortunately it is still possible to exploit the issue since two requests happening at the exact same time will still go through. You should be able to completely fix the race conditions by leveraging SQLITE write lock and just send one query. Summary The /get-patch endpoint processes a purchase in two separate database queries: a SELECT that verifies the token is unused, followed by an UPDATE that marks the token as used. Because SQLite only guards each statement, a malicious actor can issue two requests at the exact same moment and have both SELECT statements succeed before either UPDATE runs. Details The handler executes (step 1): If token_used_at IS NULL, the request passes the check (step 2): The handler finally runs (step 3): When two requests arrive at the same time, they both finish step 1 while the row is still unused. SQLite serializes writers only per statement, so each request believes it has exclusive access. Both decrypt and return the patch, and both UPDATE statements succeed. PoC To perform this attack, you need to send two requests at the exact same time. Impact An attacker who possesses a valid purchase token can replay it and receive multiple copies of the paid patch, or distribute one copy while still keeping their own. This results in revenue loss and undermines license enforcement. Remediation Replace the read-then-write sequence with a single atomic statement that both validates and consumes the token while SQLite holds the write lock:
The /get-patch handler in templates/backend-in-a-box/index.js checked and consumed the purchase token in two separate statements, a SELECT that inspected token_used_at and a later UPDATE that set it, so two simultaneous requests could both pass the check before either write landed. The fix combines both steps into one conditional UPDATE that requires token_used_at to still be null and rejects the request when this.changes is zero.
Root cause
Enhance Taysell security and usability
AI-assisted change: an AI coding tool
Fix
Race condition in /get-patch allows purchase token replay
Case facts
@@ -17,7 +17,7 @@ const IV_LENGTH = 16; const SALT_LENGTH = 16; const TAG_LENGTH = 16; const KEY_LENGTH = 32; // AES-256-const PBKDF2_ITERATIONS = 100000;+const PBKDF2_ITERATIONS = 310000; /** * Decrypts text encrypted with AES-256-GCM.@@ -48,16 +48,34 @@ function decryptAES256GCM(encryptedText, passwordKey) { // Setup PayPal environment const clientId = process.env.PAYPAL_CLIENT_ID; const clientSecret = process.env.PAYPAL_CLIENT_SECRET;+const webhookId = process.env.PAYPAL_WEBHOOK_ID; -if (!clientId || !clientSecret) {- console.error("CRITICAL ERROR: PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables are not defined.");- console.error("These variables are required for PayPal integration.");+if (!clientId || !clientSecret || !webhookId) {+ console.error("CRITICAL ERROR: PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, and PAYPAL_WEBHOOK_ID environment variables are not defined.");+ console.error("These variables are required for PayPal integration and webhook verification."); console.error("Without them, payment functionalities will not operate correctly."); } const environment = new paypal.core.SandboxEnvironment(clientId, clientSecret); const client = new paypal.core.PayPalHttpClient(environment); +// Middleware to capture raw body+app.use((req, res, next) => {+ if (req.originalUrl === '/paypal/webhook') { // Only for PayPal webhook route+ let data = '';+ req.setEncoding('utf8');+ req.on('data', chunk => {+ data += chunk;+ });+ req.on('end', () => {+ req.rawBody = data;+ next();+ });+ } else {+ next();+ }+});+ app.use(express.json()); app.use(express.static('public')); @@ -84,7 +102,8 @@ const db = new sqlite3.Database(DB_PATH, (err) => { paypal_order_id TEXT UNIQUE, status TEXT NOT NULL, cli_session_id TEXT UNIQUE,- created_at DATETIME DEFAULT CURRENT_TIMESTAMP+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,+ token_used_at DATETIME DEFAULT NULL )`, (err) => { if (err) { console.error("Error creating 'purchases' table:", err.message);@@ -159,27 +178,93 @@ app.get('/pay/:patchId', async (req, res) => { }); app.post('/paypal/webhook', async (req, res) => {- const webhookEvent = req.body;- if (webhookEvent.event_type === 'CHECKOUT.ORDER.APPROVED' || webhookEvent.event_type === 'CHECKOUT.ORDER.COMPLETED') {- const orderID = webhookEvent.resource.id;- const purchaseToken = crypto.randomBytes(16).toString('hex');- db.run(`UPDATE purchases SET status = 'COMPLETED', purchase_token = ? WHERE paypal_order_id = ?`,- [purchaseToken, orderID],- function(err) {+ const transmissionId = req.headers['paypal-transmission-id'];+ const transmissionTime = req.headers['paypal-transmission-time'];+ const transmissionSig = req.headers['paypal-transmission-sig'];+ const certUrl = req.headers['paypal-cert-url'];+ const authAlgo = req.headers['paypal-auth-algo'];+ const requestBody = req.rawBody || JSON.stringify(req.body); // PayPal SDK requires the raw body++ try {+ // Note: Accessing webhooks directly from the paypal object as per documentation+ const { verified, event: verifiedEvent } = await paypal.webhooks.Webhooks.verifyAndGetWebhookEvent(+ transmissionId,+ transmissionTime,+ transmissionSig,+ certUrl,+ webhookId, // PAYPAL_WEBHOOK_ID from env+ requestBody,+ authAlgo+ );++ if (!verified) {+ console.error("Webhook verification failed.");+ return res.status(403).send("Webhook verification failed.");@@ -172,6 +172,27 @@ export async function handleBuyCommand( console.log(`Purchase and application of patch '${metadata.name}' completed.`); } } catch (error: any) {- printUsageAndExit(`CRITICAL ERROR: Failed to retrieve patch. Details: ${error.message}`);+ // --- Enhanced Error Handling for Download Failure ---+ console.error("\n--- Payment Succeeded, Download Failed ---");+ console.error("An error occurred while attempting to download the patch after your payment was processed.");+ console.error("Please contact the seller for assistance.\n");++ const sellerContact = taysellData.sellerInfo.contact;+ const patchName = taysellData.metadata.name;++ console.error(`Seller Contact: ${sellerContact}\n`);+ console.error("Please provide them with the following information:\n");++ const subject = `Download issue for Taylored purchase: "${patchName}"`;+ const body = `Hello,\n\nI recently purchased the patch "${patchName}" (Patch ID: ${patchId}) and my payment was successful.\nHowever, the download failed.\n\nMy Purchase Token is: ${purchaseToken}\n\nPlease assist me in obtaining the patch.\n\nThank you.`;++ console.error("--- Pre-formatted Email ---");+ console.error(`To: ${sellerContact}`);+ console.error(`Subject: ${subject}\n`);+ console.error("Body:\n");+ console.error(body);+ console.error("\n---------------------------\n");+ console.error(`CRITICAL ERROR: Failed to retrieve patch. Details: ${error.message}`);+ process.exit(1); } }@@ -9,7 +9,7 @@ const IV_LENGTH = 16; // For GCM, 12 is recommended, but 16 is also common. Node const SALT_LENGTH = 16; const TAG_LENGTH = 16; const KEY_LENGTH = 32; // AES-256-const PBKDF2_ITERATIONS = 100000; // OWASP recommended minimum+const PBKDF2_ITERATIONS = 310000; // OWASP recommended minimum /** * Encrypts text using AES-256-GCM.@@ -3,7 +3,6 @@ const express = require('express'); const fs = require('fs').promises; const sqlite3 = require('sqlite3').verbose(); const crypto = require('crypto');-// const paypal = require('@paypal/paypal-server-sdk'); // Removed const path = require('path'); const axios = require('axios'); // Make sure axios is required @@ -294,27 +293,35 @@ app.post('/get-patch', async (req, res) => { return res.status(400).json({ error: "Invalid patchId format." }); } - db.get(`SELECT id, token_used_at FROM purchases WHERE patch_id = ? AND purchase_token = ? AND status = 'COMPLETED'`, [sanitizedPatchId, purchaseToken], async (err, row) => {- if (err || !row) {- return res.status(401).json({ error: "Invalid or unauthorized purchase token." });+ const sql = `+ UPDATE purchases+ SET token_used_at = CURRENT_TIMESTAMP+ WHERE patch_id = ?+ AND purchase_token = ?+ AND status = 'COMPLETED'+ AND token_used_at IS NULL+ `;++ // Use a standard function() callback to access `this.changes`.+ db.run(sql, [sanitizedPatchId, purchaseToken], async function(err) {+ if (err) {+ console.error("Database error during patch retrieval:", err.message);+ return res.status(500).json({ error: "Internal server error." }); }- if (row.token_used_at) {- return res.status(403).json({ error: "Purchase token has already been used." });++ // `this.changes` will be 1 if a row was updated (token was valid and unused), 0 otherwise.+ if (this.changes === 0) {+ return res.status(403).json({ error: "Invalid or already-used purchase token." }); } + // If we get here, the token was valid and has been consumed. Proceed to send the patch. const encryptedFilePath = path.join(__dirname, 'patches', `${sanitizedPatchId}.taylored.enc`); try { const encryptedContent = await fs.readFile(encryptedFilePath, 'utf-8'); const decryptedContent = decryptAES256GCM(encryptedContent, encryptionKey);-- db.run(`UPDATE purchases SET token_used_at = CURRENT_TIMESTAMP WHERE id = ?`, [row.id], (updateErr) => {- if (updateErr) {- console.error(`Failed to mark token as used for purchase ID ${row.id}:`, updateErr.message);- return res.status(500).json({ error: "Server error processing request." });- }- res.setHeader('Content-Type', 'text/plain');- res.status(200).send(decryptedContent);- });+ + res.setHeader('Content-Type', 'text/plain');+ res.status(200).send(decryptedContent); } catch (fileError) { if (fileError.code === 'ENOENT') { return res.status(404).json({ error: "Patch not found." });@@ -1,6 +1,6 @@ { "name": "taylored",- "version": "8.1.2",+ "version": "8.1.3", "description": "Make changes to a branch a plugin. A command-line tool to manage and apply plugins '.taylored'. Supports applying, removing, verifying plugins, and generating them from branch (GIT).", "keywords": [ "plugins",AI-assisted change c8159b3e75b9874c8d680516386c363526cb9107f40b722b6a26408a3f9c7130 · Fix dbc538bdd088bcbf8ff09cf1b3843d2492736759a499d61184bf3aaf4b9d2d8a