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.
@@ -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.151",
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.5.1",
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']