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)
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
- Retry-safe handlers
- Event logging and replay support
- Signature verification (`X-Uber-Signature` HMAC SHA256 with client secret)
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"
],
"responses": {
"202": {
"description": "Webhook accepted"
"200": {
"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",
"request": {

View File

@ -18,7 +18,14 @@ const app = express();
app.use(helmet());
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(requestContext);

View File

@ -9,6 +9,11 @@ const envSchema = z.object({
UBER_REDIRECT_URI: z.string().url(),
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_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"),
WRAPPER_API_KEY: z.string().optional()
});

View File

@ -96,12 +96,33 @@ const uberConnectionRepository = {
};
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 = {
id: uuidv4(),
provider,
merchant_id: merchantId || 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 || {}),
headers_json: JSON.stringify(headersJson || {}),
received_at: nowIso(),
@ -109,11 +130,13 @@ const webhookRepository = {
};
const stmt = db.prepare(`
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
)
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
)
`);

View File

@ -11,6 +11,11 @@ if (!fs.existsSync(dbDir)) {
const db = new Database(env.SQLITE_PATH);
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() {
db.exec(`
CREATE TABLE IF NOT EXISTS merchants (
@ -45,6 +50,10 @@ function initSchema() {
provider TEXT NOT NULL,
merchant_id TEXT,
event_type TEXT,
resource_id TEXT,
resource_href TEXT,
uber_signature TEXT,
dedupe_key TEXT,
payload_json TEXT NOT NULL,
headers_json TEXT,
received_at TEXT NOT NULL,
@ -88,6 +97,24 @@ function initSchema() {
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 = {

View File

@ -1,25 +1,112 @@
const crypto = require("crypto");
const env = require("../../config/env");
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) {
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 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",
merchantId,
eventType,
resourceId,
resourceHref,
uberSignature: verification.signature,
dedupeKey,
payloadJson: req.body,
headersJson: req.headers
});
return res.status(202).json({
success: true,
message: "Webhook received",
data: { webhookEventId: row.id }
});
return res.status(200).end();
}
module.exports = {
handleUberWebhook
};

View File

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