screwdriver-api 8.0.151 → 8.0.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/default.yaml +14 -0
- package/package.json +2 -2
- package/plugins/helper.js +18 -1
- package/plugins/lock.js +1 -15
- package/plugins/webhooks/dedupStore.js +112 -0
- package/plugins/webhooks/index.js +15 -0
package/config/default.yaml
CHANGED
|
@@ -278,6 +278,20 @@ scms: {}
|
|
|
278
278
|
# # SCM clone type (https or ssh)
|
|
279
279
|
# cloneType: https
|
|
280
280
|
webhooks:
|
|
281
|
+
# Replay protection for incoming webhook deliveries. Dedupes identical
|
|
282
|
+
# x-github-delivery values within a short TTL so a captured webhook cannot be
|
|
283
|
+
# replayed by an attacker. The Redis backend reuses the connection configured
|
|
284
|
+
# under redisLock.options.redisConnection (a single Redis instance generally
|
|
285
|
+
# serves both features). When no Redis host is configured, falls back to a
|
|
286
|
+
# per-process in-memory store. Fail-open: infrastructure errors never block
|
|
287
|
+
# legitimate webhook deliveries.
|
|
288
|
+
replayProtection:
|
|
289
|
+
# Master switch. Set to false to disable replay protection entirely.
|
|
290
|
+
enabled: true
|
|
291
|
+
# Window in which a duplicate x-github-delivery is rejected. Defaults to 5
|
|
292
|
+
# minutes — long enough to defeat realistic fast-replay attempts, short
|
|
293
|
+
# enough that legitimate manual redelivery from the SCM UI is unaffected.
|
|
294
|
+
ttlSeconds: 300
|
|
281
295
|
scms:
|
|
282
296
|
github:
|
|
283
297
|
# Obtains the SCM token for a given user. If a user does not have a valid SCM token registered with Screwdriver, it will use this user's token instead.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screwdriver-api",
|
|
3
|
-
"version": "8.0.
|
|
3
|
+
"version": "8.0.152",
|
|
4
4
|
"description": "API server for the Screwdriver.cd service",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
"screwdriver-request": "^3.0.0",
|
|
130
130
|
"screwdriver-scm-base": "^10.0.1",
|
|
131
131
|
"screwdriver-scm-bitbucket": "^7.4.1",
|
|
132
|
-
"screwdriver-scm-github": "^14.
|
|
132
|
+
"screwdriver-scm-github": "^14.6.2",
|
|
133
133
|
"screwdriver-scm-gitlab": "^5.5.1",
|
|
134
134
|
"screwdriver-scm-router": "^9.0.0",
|
|
135
135
|
"screwdriver-template-validator": "^10.0.0",
|
package/plugins/helper.js
CHANGED
|
@@ -133,6 +133,22 @@ function isStageTeardown(jobName) {
|
|
|
133
133
|
return STAGE_TEARDOWN_PATTERN.test(jobName);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Coerce a YAML-derived boolean-or-string into a Boolean. Accepts the
|
|
138
|
+
* common YAML 1.1 truthy strings ('on', 'true', 'yes', 'y') for
|
|
139
|
+
* consistency with the convention historically used by plugins/lock.js.
|
|
140
|
+
* @method parseBool
|
|
141
|
+
* @param {Boolean|String} value
|
|
142
|
+
* @returns {Boolean}
|
|
143
|
+
*/
|
|
144
|
+
function parseBool(value) {
|
|
145
|
+
if (typeof value === 'boolean') {
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return ['on', 'true', 'yes', 'y'].includes(String(value).toLowerCase());
|
|
150
|
+
}
|
|
151
|
+
|
|
136
152
|
module.exports = {
|
|
137
153
|
getReadOnlyInfo,
|
|
138
154
|
getScmUri,
|
|
@@ -140,5 +156,6 @@ module.exports = {
|
|
|
140
156
|
setDefaultTimeRange,
|
|
141
157
|
validTimeRange,
|
|
142
158
|
getFullStageJobName,
|
|
143
|
-
isStageTeardown
|
|
159
|
+
isStageTeardown,
|
|
160
|
+
parseBool
|
|
144
161
|
};
|
package/plugins/lock.js
CHANGED
|
@@ -4,21 +4,7 @@ const Redis = require('ioredis');
|
|
|
4
4
|
const Redlock = require('redlock');
|
|
5
5
|
const config = require('config');
|
|
6
6
|
const logger = require('screwdriver-logger');
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* parse value to Boolean
|
|
10
|
-
* @method parseBool
|
|
11
|
-
* @param {(Boolean|String)} value
|
|
12
|
-
* @return {Boolean}
|
|
13
|
-
*/
|
|
14
|
-
function parseBool(value) {
|
|
15
|
-
if (typeof value === 'boolean') {
|
|
16
|
-
return value;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// True values refers to https://yaml.org/type/bool.html
|
|
20
|
-
return ['on', 'true', 'yes', 'y'].includes(String(value).toLowerCase());
|
|
21
|
-
}
|
|
7
|
+
const { parseBool } = require('./helper');
|
|
22
8
|
|
|
23
9
|
class Lock {
|
|
24
10
|
/**
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('config');
|
|
4
|
+
const logger = require('screwdriver-logger');
|
|
5
|
+
const lock = require('../lock');
|
|
6
|
+
const { parseBool } = require('../helper');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Webhook replay-protection store. Tracks `x-github-delivery` IDs (or the
|
|
10
|
+
* equivalent for other SCMs) for a short TTL and reports whether an incoming
|
|
11
|
+
* delivery is fresh or a duplicate.
|
|
12
|
+
*
|
|
13
|
+
* Reuses the Redis client that plugins/lock.js creates from
|
|
14
|
+
* redisLock.options. When that client is unavailable (redisLock.enabled is
|
|
15
|
+
* false, or Redis init failed), falls back to a per-process in-memory Map.
|
|
16
|
+
* The in-memory path is per-process and does NOT protect across multiple API
|
|
17
|
+
* instances — it's a fail-degraded fallback for deployments where Redis is
|
|
18
|
+
* unavailable.
|
|
19
|
+
*
|
|
20
|
+
* All errors are caught and the caller is told the delivery is fresh
|
|
21
|
+
* (fail-open) — webhook processing is never blocked because of replay-
|
|
22
|
+
* protection infrastructure failure.
|
|
23
|
+
*
|
|
24
|
+
* Exported as a singleton, mirroring plugins/lock.js.
|
|
25
|
+
*/
|
|
26
|
+
class WebhookDedupStore {
|
|
27
|
+
/**
|
|
28
|
+
* Read config and initialize the store. webhooks.replayProtection holds
|
|
29
|
+
* { enabled: Boolean, ttlSeconds: Number }
|
|
30
|
+
* Redis is sourced from plugins/lock.js, which is in turn driven by
|
|
31
|
+
* redisLock.options — operators configure one Redis target, both features
|
|
32
|
+
* use it.
|
|
33
|
+
*/
|
|
34
|
+
constructor() {
|
|
35
|
+
// Tolerate `replayProtection: null` in YAML — config.has() reports
|
|
36
|
+
// true but config.get() returns null in that case. Coalesce to {}
|
|
37
|
+
// so the rest of the constructor reads default values.
|
|
38
|
+
const options =
|
|
39
|
+
(config.has('webhooks.replayProtection') ? config.get('webhooks.replayProtection') : null) || {};
|
|
40
|
+
|
|
41
|
+
this.enabled = parseBool(options.enabled);
|
|
42
|
+
this.ttlSeconds = parseInt(options.ttlSeconds, 10) || 300;
|
|
43
|
+
|
|
44
|
+
if (!this.enabled) {
|
|
45
|
+
this.redis = null;
|
|
46
|
+
this.memorySeen = null;
|
|
47
|
+
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.redis = lock.redis || null;
|
|
52
|
+
this.memorySeen = this.redis ? null : new Map();
|
|
53
|
+
|
|
54
|
+
if (this.redis) {
|
|
55
|
+
// ioredis emits 'error' events on connection / protocol failures.
|
|
56
|
+
// Without a listener, an unhandled error event crashes the Node
|
|
57
|
+
// process. lock.js owns this client but doesn't register a
|
|
58
|
+
// handler, so we register one here. Listener is attached once
|
|
59
|
+
// because dedupStore is a module-level singleton.
|
|
60
|
+
this.redis.on('error', err => {
|
|
61
|
+
logger.warn(`Webhook dedup store: Redis error (failing open): ${err.message}`);
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
logger.info(
|
|
65
|
+
'Webhook dedup store: Redis not available (redisLock disabled or uninitialized), using in-memory fallback (single-instance protection only)'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Attempt to claim a dedup key. Returns true if this is the first time the
|
|
72
|
+
* key has been seen within the TTL window (fresh delivery — proceed), or
|
|
73
|
+
* false if the key was already claimed (duplicate — reject upstream).
|
|
74
|
+
*
|
|
75
|
+
* Any infrastructure error returns true (fail-open).
|
|
76
|
+
*
|
|
77
|
+
* @method claim
|
|
78
|
+
* @param {String} key
|
|
79
|
+
* @returns {Promise<Boolean>} true if fresh, false if duplicate
|
|
80
|
+
*/
|
|
81
|
+
async claim(key) {
|
|
82
|
+
if (!this.enabled) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (this.redis) {
|
|
87
|
+
try {
|
|
88
|
+
// SET key 1 EX <ttl> NX — atomic test-and-set with TTL.
|
|
89
|
+
// Resolves to 'OK' on success, null if the key already exists.
|
|
90
|
+
const result = await this.redis.set(key, '1', 'EX', this.ttlSeconds, 'NX');
|
|
91
|
+
|
|
92
|
+
return result === 'OK';
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logger.warn(`Webhook dedup store: claim failed (failing open): ${err.message}`);
|
|
95
|
+
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// In-memory fallback path. Constructor guarantees memorySeen is set
|
|
101
|
+
// here (when enabled and lock.redis is unavailable).
|
|
102
|
+
if (this.memorySeen.has(key)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
this.memorySeen.set(key, true);
|
|
106
|
+
setTimeout(() => this.memorySeen.delete(key), this.ttlSeconds * 1000).unref();
|
|
107
|
+
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = new WebhookDedupStore();
|
|
@@ -5,6 +5,7 @@ const logger = require('screwdriver-logger');
|
|
|
5
5
|
const boom = require('@hapi/boom');
|
|
6
6
|
const { ValidationError } = require('joi');
|
|
7
7
|
const { startHookEvent } = require('./helper');
|
|
8
|
+
const dedupStore = require('./dedupStore');
|
|
8
9
|
|
|
9
10
|
const DEFAULT_MAX_BYTES = 1048576; // 1MB
|
|
10
11
|
const providerSchema = joi
|
|
@@ -99,6 +100,20 @@ const webhooksPlugin = {
|
|
|
99
100
|
|
|
100
101
|
request.log(['webhook', hookId], `Received event type ${type}`);
|
|
101
102
|
|
|
103
|
+
// Replay protection: dedupe identical x-github-delivery within a short window.
|
|
104
|
+
// On duplicate, return 204 No Content with no body so the SCM stops
|
|
105
|
+
// retrying and an attacker probing IDs cannot distinguish a seen ID from
|
|
106
|
+
// an unseen one based on the response. The replay decision is recorded
|
|
107
|
+
// server-side via request.log. Fail-open is handled inside dedupStore.claim().
|
|
108
|
+
const dedupKey = `webhook:${parsed.scmContext}:${hookId}`;
|
|
109
|
+
const fresh = await dedupStore.claim(dedupKey);
|
|
110
|
+
|
|
111
|
+
if (!fresh) {
|
|
112
|
+
request.log(['webhook', hookId], 'Duplicate delivery — skipping (replay protection)');
|
|
113
|
+
|
|
114
|
+
return h.response().code(204);
|
|
115
|
+
}
|
|
116
|
+
|
|
102
117
|
if (queueWebhookEnabled) {
|
|
103
118
|
parsed.token = request.server.plugins.auth.generateToken({
|
|
104
119
|
scope: ['sdapi']
|