feat: harden uber webhooks with signature validation, dedupe, and 200 ack behavior

This commit is contained in:
MOHAN 2026-03-29 17:48:09 +05:30
parent 042dc1686e
commit 490d2e77b7
11 changed files with 237 additions and 16 deletions

View File

@ -16,3 +16,9 @@ SQLITE_PATH=./data/uber_wrapper.db
# Shared API key for wrapper clients (optional but recommended) # Shared API key for wrapper clients (optional but recommended)
WRAPPER_API_KEY=change-me WRAPPER_API_KEY=change-me
# Webhook security
UBER_WEBHOOK_SIGNATURE_REQUIRED=true
# Optional: if you configure Basic Auth in Uber dashboard for webhook delivery
WEBHOOK_BASIC_AUTH_USERNAME=
WEBHOOK_BASIC_AUTH_PASSWORD=

View File

@ -0,0 +1,30 @@
# 07 Webhooks Audit
Source checked: Uber Eats "Webhook" section shared by you.
## Implemented Now
- Webhook signature verification using `X-Uber-Signature`:
- HMAC SHA256 over raw request body
- key = Uber client secret
- Optional webhook Basic Auth validation if configured in `.env`.
- Retry-safe de-duplication via deterministic webhook dedupe key.
- Response behavior aligned to doc:
- return `200` with empty body on valid webhook receipt.
- Persisted webhook metadata includes:
- `event_type`
- `resource_id`
- `resource_href`
- signature and dedupe key
## Existing Before
- Webhook endpoint already present.
- Event payload and headers persistence.
## Pending
- Per-event downstream job workers (accept/deny SLA orchestration).
- Alerting if order accept/deny not sent before timeout window.
- Full typed schema validation per webhook `event_type`.

View File

@ -6,6 +6,11 @@ Webhook design goals:
- Idempotent processing - Idempotent processing
- Retry-safe handlers - Retry-safe handlers
- Event logging and replay support - Event logging and replay support
- Signature verification (`X-Uber-Signature` HMAC SHA256 with client secret)
Current ingestion endpoint: `POST /api/v1/webhooks/uber` Current ingestion endpoint: `POST /api/v1/webhooks/uber`
Acknowledgement behavior:
- Valid webhook events are acknowledged with `200` and empty body
- Duplicate retries are de-duplicated and still acknowledged with `200`

View File

@ -323,8 +323,8 @@
"Webhooks" "Webhooks"
], ],
"responses": { "responses": {
"202": { "200": {
"description": "Webhook accepted" "description": "Webhook accepted (empty body)"
} }
} }
} }

View File

@ -199,6 +199,38 @@
} }
} }
}, },
{
"name": "Webhook Ingest (Simulation)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Uber-Signature",
"value": "replace-with-valid-hmac"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"event_type\": \"orders.notification\",\n \"resource_id\": \"ORDER_ID\",\n \"resource_href\": \"/v1/eats/orders/ORDER_ID\",\n \"order\": {\n \"id\": \"ORDER_ID\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/v1/webhooks/uber",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"webhooks",
"uber"
]
}
}
},
{ {
"name": "Generic Uber Request", "name": "Generic Uber Request",
"request": { "request": {

View File

@ -18,7 +18,14 @@ const app = express();
app.use(helmet()); app.use(helmet());
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "5mb" })); app.use(
express.json({
limit: "5mb",
verify: (req, res, buf) => {
req.rawBody = Buffer.from(buf);
}
})
);
app.use(morgan("combined")); app.use(morgan("combined"));
app.use(requestContext); app.use(requestContext);

View File

@ -9,6 +9,11 @@ const envSchema = z.object({
UBER_REDIRECT_URI: z.string().url(), UBER_REDIRECT_URI: z.string().url(),
UBER_OAUTH_BASE_URL: z.string().url().default("https://auth.uber.com"), UBER_OAUTH_BASE_URL: z.string().url().default("https://auth.uber.com"),
UBER_API_BASE_URL: z.string().url().default("https://api.uber.com"), UBER_API_BASE_URL: z.string().url().default("https://api.uber.com"),
UBER_WEBHOOK_SIGNATURE_REQUIRED: z
.preprocess((value) => String(value ?? "true").toLowerCase(), z.enum(["true", "false"]))
.transform((value) => value === "true"),
WEBHOOK_BASIC_AUTH_USERNAME: z.string().optional(),
WEBHOOK_BASIC_AUTH_PASSWORD: z.string().optional(),
SQLITE_PATH: z.string().default("./data/uber_wrapper.db"), SQLITE_PATH: z.string().default("./data/uber_wrapper.db"),
WRAPPER_API_KEY: z.string().optional() WRAPPER_API_KEY: z.string().optional()
}); });

View File

@ -96,12 +96,33 @@ const uberConnectionRepository = {
}; };
const webhookRepository = { const webhookRepository = {
insert({ provider, merchantId, eventType, payloadJson, headersJson }) { findByDedupeKey(dedupeKey) {
if (!dedupeKey) {
return null;
}
return db.prepare("SELECT * FROM webhook_events WHERE dedupe_key = ? LIMIT 1").get(dedupeKey);
},
insert({
provider,
merchantId,
eventType,
resourceId,
resourceHref,
uberSignature,
dedupeKey,
payloadJson,
headersJson
}) {
const row = { const row = {
id: uuidv4(), id: uuidv4(),
provider, provider,
merchant_id: merchantId || null, merchant_id: merchantId || null,
event_type: eventType || null, event_type: eventType || null,
resource_id: resourceId || null,
resource_href: resourceHref || null,
uber_signature: uberSignature || null,
dedupe_key: dedupeKey || null,
payload_json: JSON.stringify(payloadJson || {}), payload_json: JSON.stringify(payloadJson || {}),
headers_json: JSON.stringify(headersJson || {}), headers_json: JSON.stringify(headersJson || {}),
received_at: nowIso(), received_at: nowIso(),
@ -109,11 +130,13 @@ const webhookRepository = {
}; };
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO webhook_events ( INSERT INTO webhook_events (
id, provider, merchant_id, event_type, payload_json, headers_json, id, provider, merchant_id, event_type, resource_id, resource_href, uber_signature, dedupe_key,
payload_json, headers_json,
received_at, processing_status received_at, processing_status
) )
VALUES ( VALUES (
@id, @provider, @merchant_id, @event_type, @payload_json, @headers_json, @id, @provider, @merchant_id, @event_type, @resource_id, @resource_href, @uber_signature, @dedupe_key,
@payload_json, @headers_json,
@received_at, @processing_status @received_at, @processing_status
) )
`); `);

View File

@ -11,6 +11,11 @@ if (!fs.existsSync(dbDir)) {
const db = new Database(env.SQLITE_PATH); const db = new Database(env.SQLITE_PATH);
db.pragma("journal_mode = WAL"); db.pragma("journal_mode = WAL");
function tableHasColumn(tableName, columnName) {
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
return columns.some((column) => column.name === columnName);
}
function initSchema() { function initSchema() {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS merchants ( CREATE TABLE IF NOT EXISTS merchants (
@ -45,6 +50,10 @@ function initSchema() {
provider TEXT NOT NULL, provider TEXT NOT NULL,
merchant_id TEXT, merchant_id TEXT,
event_type TEXT, event_type TEXT,
resource_id TEXT,
resource_href TEXT,
uber_signature TEXT,
dedupe_key TEXT,
payload_json TEXT NOT NULL, payload_json TEXT NOT NULL,
headers_json TEXT, headers_json TEXT,
received_at TEXT NOT NULL, received_at TEXT NOT NULL,
@ -88,6 +97,24 @@ function initSchema() {
requested_at TEXT NOT NULL requested_at TEXT NOT NULL
); );
`); `);
if (!tableHasColumn("webhook_events", "resource_id")) {
db.exec("ALTER TABLE webhook_events ADD COLUMN resource_id TEXT");
}
if (!tableHasColumn("webhook_events", "resource_href")) {
db.exec("ALTER TABLE webhook_events ADD COLUMN resource_href TEXT");
}
if (!tableHasColumn("webhook_events", "uber_signature")) {
db.exec("ALTER TABLE webhook_events ADD COLUMN uber_signature TEXT");
}
if (!tableHasColumn("webhook_events", "dedupe_key")) {
db.exec("ALTER TABLE webhook_events ADD COLUMN dedupe_key TEXT");
}
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_events_dedupe_key
ON webhook_events(dedupe_key);
`);
} }
module.exports = { module.exports = {

View File

@ -1,25 +1,112 @@
const crypto = require("crypto");
const env = require("../../config/env");
const { webhookRepository } = require("../../db/adapter"); const { webhookRepository } = require("../../db/adapter");
function getSignatureFromHeaders(headers) {
const signature = headers["x-uber-signature"];
if (!signature) {
return null;
}
if (Array.isArray(signature)) {
return String(signature[0] || "").toLowerCase();
}
return String(signature).toLowerCase();
}
function getRawBody(req) {
if (req.rawBody && Buffer.isBuffer(req.rawBody)) {
return req.rawBody;
}
return Buffer.from(JSON.stringify(req.body || {}), "utf8");
}
function verifyBasicAuthIfConfigured(req) {
const expectedUser = env.WEBHOOK_BASIC_AUTH_USERNAME || "";
const expectedPass = env.WEBHOOK_BASIC_AUTH_PASSWORD || "";
if (!expectedUser && !expectedPass) {
return true;
}
const authHeader = req.headers.authorization || "";
if (!authHeader.startsWith("Basic ")) {
return false;
}
const encoded = authHeader.slice(6).trim();
const decoded = Buffer.from(encoded, "base64").toString("utf8");
const [user, pass] = decoded.split(":");
return user === expectedUser && pass === expectedPass;
}
function verifyUberSignature(req) {
const signature = getSignatureFromHeaders(req.headers);
if (!signature) {
return { ok: !env.UBER_WEBHOOK_SIGNATURE_REQUIRED, signature: null };
}
const rawBody = getRawBody(req);
const expected = crypto
.createHmac("sha256", env.UBER_CLIENT_SECRET)
.update(rawBody)
.digest("hex")
.toLowerCase();
const incomingBuffer = Buffer.from(signature, "utf8");
const expectedBuffer = Buffer.from(expected, "utf8");
const sameLength = incomingBuffer.length === expectedBuffer.length;
const valid =
sameLength && crypto.timingSafeEqual(incomingBuffer, expectedBuffer);
return { ok: valid, signature };
}
function buildDedupeKey(signature, req) {
const rawBody = getRawBody(req);
const basis = `${signature || "no-signature"}:${rawBody.toString("utf8")}`;
return crypto.createHash("sha256").update(basis).digest("hex");
}
async function handleUberWebhook(req, res) { async function handleUberWebhook(req, res) {
if (!verifyBasicAuthIfConfigured(req)) {
return res.status(401).json({
success: false,
message: "Invalid webhook basic auth credentials"
});
}
const verification = verifyUberSignature(req);
if (!verification.ok) {
return res.status(401).json({
success: false,
message: "Invalid or missing X-Uber-Signature"
});
}
const merchantId = req.query.merchantId || req.body?.merchant_id || null; const merchantId = req.query.merchantId || req.body?.merchant_id || null;
const eventType = req.body?.event_type || req.body?.type || "unknown"; const eventType = req.body?.event_type || req.body?.type || "unknown";
const resourceId = req.body?.resource_id || req.body?.order_id || req.body?.order?.id || null;
const resourceHref = req.body?.resource_href || null;
const dedupeKey = buildDedupeKey(verification.signature, req);
const row = webhookRepository.insert({ const existing = webhookRepository.findByDedupeKey(dedupeKey);
if (existing) {
return res.status(200).end();
}
webhookRepository.insert({
provider: "uber", provider: "uber",
merchantId, merchantId,
eventType, eventType,
resourceId,
resourceHref,
uberSignature: verification.signature,
dedupeKey,
payloadJson: req.body, payloadJson: req.body,
headersJson: req.headers headersJson: req.headers
}); });
return res.status(202).json({ return res.status(200).end();
success: true,
message: "Webhook received",
data: { webhookEventId: row.id }
});
} }
module.exports = { module.exports = {
handleUberWebhook handleUberWebhook
}; };

View File

@ -12,10 +12,9 @@ const router = express.Router();
* tags: * tags:
* - Webhooks * - Webhooks
* responses: * responses:
* 202: * 200:
* description: Webhook accepted * description: Webhook accepted (empty body)
*/ */
router.post("/webhooks/uber", asyncHandler(handleUberWebhook)); router.post("/webhooks/uber", asyncHandler(handleUberWebhook));
module.exports = router; module.exports = router;