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)
|
||||
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
|
||||
- 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`
|
||||
|
||||
@ -323,8 +323,8 @@
|
||||
"Webhooks"
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Webhook accepted"
|
||||
"200": {
|
||||
"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",
|
||||
"request": {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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()
|
||||
});
|
||||
|
||||
@ -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
|
||||
)
|
||||
`);
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user