feat: harden uber webhooks with signature validation, dedupe, and 200 ack behavior
This commit is contained in:
parent
042dc1686e
commit
490d2e77b7
@ -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=
|
||||||
|
|||||||
30
docs/developer-portal/07-webhooks-audit.md
Normal file
30
docs/developer-portal/07-webhooks-audit.md
Normal 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`.
|
||||||
|
|
||||||
@ -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`
|
||||||
|
|||||||
@ -323,8 +323,8 @@
|
|||||||
"Webhooks"
|
"Webhooks"
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"202": {
|
"200": {
|
||||||
"description": "Webhook accepted"
|
"description": "Webhook accepted (empty body)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user