Skip to content

Commit 6ff9be7

Browse files
committed
fix(security): close 6 OAuth backend + nx-claude script scan findings
Slack OAuth backend (apps/slack-oauth-backend): - #1 (HIGH) CSRF: replace the length-only state check with a stateless HMAC-SHA256 signed state token (new src/oauth/state.ts). generateState signs a CSPRNG nonce + timestamp with config.sessionSecret and base64url- encodes it; validateState recomputes the HMAC with timingSafeEqual and a 10-min freshness window. Stateless by design so it works on Vercel serverless (no shared store); base64url satisfies the existing callback validator charset/length bounds. - #2 (MED) weak randomness: nonce now from crypto.randomBytes(16), not Math.random()/Date.now(). - #3 (MED) tokens in cacheable HTML: add Cache-Control no-store (+no-cache/ Pragma/Expires) on all token-bearing /callback responses and the /refresh JSON; stop rendering the refresh token in the HTML fallback. - #6 (MED) unauthenticated /slack/refresh: proportionate hardening (rate limit 10->5/min, no-store header, documented threat model). The refresh_token is itself the bearer credential in a stateless flow, so per-caller auth is intentionally not added. nx-claude scripts (packages/ai-toolkit-nx-claude): - #4 (MED) bash arithmetic command injection: source validate-numeric.sh and validate registry-derived values (^[0-9]+$) before $((...)) in reset-prerelease-version.sh. - #5 (MED) shell injection in generated slack-env.sh: POSIX single-quote escaping (shSingleQuote) in createSlackEnvFile and updateRefreshToken; the latter also switches String.replace to a replacement function to neutralize $-substitution corruption. Verification: 83/83 backend tests pass (integration suite updated to mint real signed states); typecheck + lint clean on both projects; #4 and #5 functionally verified (injection payload rejected, metacharacters inert); code-reviewer pass: READY TO MERGE. Finding #7 (heredoc delimiter) was already fixed on next by PR #509.
1 parent 27e7e2d commit 6ff9be7

8 files changed

Lines changed: 219 additions & 31 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Mock the config module with a deterministic session secret
2+
jest.mock('../config', () => ({
3+
config: {
4+
sessionSecret: 'test-session-secret-deterministic',
5+
},
6+
}));
7+
8+
import { generateState, validateState } from './state';
9+
10+
describe('OAuth State Token', () => {
11+
describe('generateState', () => {
12+
it('should produce a base64url token with no padding or unsafe chars', () => {
13+
const state = generateState();
14+
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
15+
expect(state).not.toContain('=');
16+
});
17+
18+
it('should stay well under the 500-char callback limit', () => {
19+
const state = generateState();
20+
expect(state.length).toBeGreaterThanOrEqual(16);
21+
expect(state.length).toBeLessThan(500);
22+
});
23+
24+
it('should produce a different token on each call', () => {
25+
expect(generateState()).not.toEqual(generateState());
26+
});
27+
});
28+
29+
describe('validateState', () => {
30+
it('should validate a freshly generated state', () => {
31+
const state = generateState();
32+
expect(validateState(state)).toBe(true);
33+
});
34+
35+
it('should reject an arbitrary 16+ char string', () => {
36+
expect(validateState('aaaaaaaaaaaaaaaa')).toBe(false);
37+
});
38+
39+
it('should reject a tampered token (flipped char)', () => {
40+
const state = generateState();
41+
const idx = Math.floor(state.length / 2);
42+
const original = state[idx];
43+
const replacement = original === 'A' ? 'B' : 'A';
44+
const tampered = state.slice(0, idx) + replacement + state.slice(idx + 1);
45+
expect(validateState(tampered)).toBe(false);
46+
});
47+
48+
it('should reject an expired token via a tiny maxAgeMs', async () => {
49+
const state = generateState();
50+
await new Promise((resolve) => setTimeout(resolve, 5));
51+
expect(validateState(state, 1)).toBe(false);
52+
});
53+
54+
it('should reject an empty string', () => {
55+
expect(validateState('')).toBe(false);
56+
});
57+
58+
it('should reject malformed base64url that does not decode to 3 parts', () => {
59+
const malformed = Buffer.from('not-three-parts', 'utf8').toString('base64url');
60+
expect(validateState(malformed)).toBe(false);
61+
});
62+
});
63+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
2+
import { config } from '../config';
3+
4+
/**
5+
* Stateless, signed OAuth state tokens.
6+
*
7+
* The token is `nonce.timestamp.signature`, HMAC-SHA256 signed with
8+
* `config.sessionSecret`, then base64url-encoded with no padding. This works on
9+
* serverless (no shared store needed): /authorize signs the token and /callback
10+
* verifies the signature, so a forged or replayed state is rejected without any
11+
* cross-instance state. base64url (A-Za-z0-9-_) satisfies the callback
12+
* validator's /^[a-zA-Z0-9_-]+$/ length 16..500 check.
13+
*/
14+
15+
const DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
16+
const SEPARATOR = '.';
17+
18+
function sign(payload: string): string {
19+
return createHmac('sha256', config.sessionSecret).update(payload).digest('base64url');
20+
}
21+
22+
/**
23+
* Build a signed state token from a CSPRNG nonce and the current timestamp.
24+
*/
25+
export function generateState(): string {
26+
const nonce = randomBytes(16).toString('base64url');
27+
const timestamp = Date.now().toString();
28+
const payload = `${nonce}${SEPARATOR}${timestamp}`;
29+
const signature = sign(payload);
30+
const token = `${payload}${SEPARATOR}${signature}`;
31+
return Buffer.from(token, 'utf8').toString('base64url');
32+
}
33+
34+
/**
35+
* Verify a state token's HMAC signature and freshness. Never throws; returns
36+
* false on any decode error, signature mismatch, malformed structure, or if the
37+
* embedded timestamp is older than maxAgeMs.
38+
*/
39+
export function validateState(state: string, maxAgeMs: number = DEFAULT_MAX_AGE_MS): boolean {
40+
try {
41+
if (typeof state !== 'string' || state.length === 0) {
42+
return false;
43+
}
44+
45+
const decoded = Buffer.from(state, 'base64url').toString('utf8');
46+
const parts = decoded.split(SEPARATOR);
47+
if (parts.length !== 3) {
48+
return false;
49+
}
50+
51+
const [nonce, timestamp, signature] = parts;
52+
if (!nonce || !timestamp || !signature) {
53+
return false;
54+
}
55+
56+
const expectedSignature = sign(`${nonce}${SEPARATOR}${timestamp}`);
57+
const provided = Buffer.from(signature, 'utf8');
58+
const expected = Buffer.from(expectedSignature, 'utf8');
59+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
60+
return false;
61+
}
62+
63+
const issuedAt = Number(timestamp);
64+
if (!Number.isInteger(issuedAt) || issuedAt <= 0) {
65+
return false;
66+
}
67+
68+
const age = Date.now() - issuedAt;
69+
if (age < 0 || age > maxAgeMs) {
70+
return false;
71+
}
72+
73+
return true;
74+
} catch {
75+
return false;
76+
}
77+
}

apps/slack-oauth-backend/src/routes/oauth.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Request, Response, NextFunction } from 'express';
22
import { Router } from 'express';
33
import { createOAuthHandler } from '../oauth/handler';
4+
import { generateState, validateState } from '../oauth/state';
45
import { createSlackClient } from '../slack/client';
56
import { formatTokenMessage, formatSuccessPage, formatErrorPage } from '../messages/formatter';
67
import { logger } from '../utils/logger';
@@ -54,11 +55,9 @@ router.get(
5455
throw new ValidationError('Missing authorization code', 'code');
5556
}
5657

57-
// Create OAuth handler with state validation
58+
// Create OAuth handler with signed-state validation (CSRF protection)
5859
const oauthHandler = createOAuthHandler((state) => {
59-
// In production, validate state against session or database
60-
// For now, just ensure it exists and has minimum length
61-
return typeof state === 'string' && state.length >= 16;
60+
return typeof state === 'string' && validateState(state);
6261
});
6362

6463
// Handle OAuth callback
@@ -73,6 +72,11 @@ router.get(
7372
// Send error page
7473
const errorPage = formatErrorPage(result.errorCode || 'unknown', result.error);
7574

75+
res.set({
76+
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
77+
Pragma: 'no-cache',
78+
Expires: '0',
79+
});
7680
res.status(400).send(errorPage);
7781
return;
7882
}
@@ -117,12 +121,20 @@ router.get(
117121
// Send success page with token displayed if DM failed
118122
// Generate refresh URL based on request host
119123
const refreshUrl = `${req.protocol}://${req.get('host')}/slack/refresh`;
124+
// Never render the refresh token in HTML; the page can be cached by
125+
// intermediaries. The access-token fallback display is the accepted
126+
// compromise so the user can still copy a token if the DM failed.
120127
const successPage = formatSuccessPage(
121128
result.user?.name,
122129
dmSent ? undefined : result.accessToken,
123-
dmSent ? undefined : result.refreshToken,
130+
undefined,
124131
dmSent ? undefined : refreshUrl
125132
);
133+
res.set({
134+
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
135+
Pragma: 'no-cache',
136+
Expires: '0',
137+
});
126138
res.send(successPage);
127139
} catch (error) {
128140
requestLogger.error('OAuth callback error', error);
@@ -134,6 +146,11 @@ router.get(
134146
error.message
135147
);
136148

149+
res.set({
150+
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
151+
Pragma: 'no-cache',
152+
Expires: '0',
153+
});
137154
res.status(400).send(errorPage);
138155
return;
139156
}
@@ -167,17 +184,8 @@ router.get(
167184
redirectUri: parsedUrl.searchParams.get('redirect_uri'),
168185
});
169186

170-
// In production, store state in session or database
171-
// For now, just redirect
172187
res.redirect(authUrl);
173188
}
174189
);
175190

176-
/**
177-
* Generate a random state parameter
178-
*/
179-
function generateState(): string {
180-
return `state_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
181-
}
182-
183191
export default router;

apps/slack-oauth-backend/src/routes/refresh.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const router = Router();
1414
*/
1515
const refreshRateLimiter = rateLimit({
1616
windowMs: 60 * 1000, // 1 minute
17-
max: 10, // limit each IP to 10 requests per minute
17+
max: 5, // limit each IP to 5 requests per minute
1818
message: 'Too many token refresh attempts, please try again later.',
1919
standardHeaders: true,
2020
legacyHeaders: false,
@@ -96,8 +96,18 @@ const validateRefreshRequest = (req: Request, res: Response, next: NextFunction)
9696
* Token refresh endpoint
9797
* POST /slack/refresh
9898
*
99-
* Exchanges a refresh token for a new access token
100-
* This keeps the client secret server-side for security
99+
* Exchanges a refresh token for a new access token, keeping the client secret
100+
* server-side.
101+
*
102+
* Threat model: there is no per-caller auth here, and that is intentional. The
103+
* refresh_token IS the bearer credential, so possessing it is the authorization.
104+
* This flow is stateless and serverless (no shared session/cookie store across
105+
* Vercel instances), so a per-caller session check cannot be added without new
106+
* infra. Defense is layered instead: the client_secret never leaves the server,
107+
* the endpoint is IP rate-limited, and the main token-leak vector (OAuth tokens
108+
* in a cacheable HTML response) is closed by the callback's no-store headers and
109+
* by never rendering the refresh token in HTML. The no-store header below keeps
110+
* the JSON token response out of intermediary caches as well.
101111
*/
102112
router.post(
103113
'/',
@@ -176,6 +186,10 @@ router.post(
176186
});
177187

178188
// Return the new tokens
189+
res.set({
190+
'Cache-Control': 'no-store',
191+
Pragma: 'no-cache',
192+
});
179193
res.json({
180194
ok: true,
181195
access_token: accessToken,

apps/slack-oauth-backend/tests/integration/oauth-flow.spec.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Express } from 'express';
33
import express from 'express';
44
import { WebClient } from '@slack/web-api';
55
import oauthRouter from '../../src/routes/oauth';
6+
import { generateState } from '../../src/oauth/state';
67
import { clearSlackCaches } from '../../src/slack/client';
78
import type * as SecurityMiddleware from '../../src/middleware/security';
89

@@ -15,6 +16,7 @@ jest.mock('../../src/config', () => ({
1516
slackClientId: 'test-client-id',
1617
slackClientSecret: 'test-client-secret',
1718
slackRedirectUri: 'https://example.com/callback',
19+
sessionSecret: 'test-session-secret-deterministic-32chars',
1820
notionDocUrl: 'https://notion.so/setup-docs',
1921
environment: 'test',
2022
},
@@ -96,7 +98,8 @@ describe('OAuth Flow Integration Tests', () => {
9698
});
9799

98100
describe('GET /slack/oauth/callback - Success Flow', () => {
99-
const validState = '1234567890123456789';
101+
// A real HMAC-signed state, the only kind the callback now accepts.
102+
const validState = generateState();
100103
const validCode = 'valid-auth-code';
101104

102105
const mockTokenResponse = {
@@ -270,7 +273,7 @@ describe('OAuth Flow Integration Tests', () => {
270273

271274
const response = await request(app).get('/slack/oauth/callback').query({
272275
code: 'invalid-code',
273-
state: '1234567890123456',
276+
state: generateState(),
274277
});
275278

276279
expect(response.status).toBe(400);
@@ -282,7 +285,7 @@ describe('OAuth Flow Integration Tests', () => {
282285

283286
const response = await request(app).get('/slack/oauth/callback').query({
284287
code: 'valid-code',
285-
state: '1234567890123456',
288+
state: generateState(),
286289
});
287290

288291
expect(response.status).toBe(400);
@@ -309,7 +312,9 @@ describe('OAuth Flow Integration Tests', () => {
309312
expect(response.headers.location).toContain(
310313
'redirect_uri=https%3A%2F%2Fexample.com%2Fcallback'
311314
);
312-
expect(response.headers.location).toContain('state=state_');
315+
// State is now an HMAC-signed base64url token (A-Za-z0-9-_), not the old
316+
// predictable `state_<ts>_<rand>` format.
317+
expect(response.headers.location).toMatch(/state=[A-Za-z0-9_-]{16,}/);
313318
});
314319
});
315320

@@ -366,7 +371,7 @@ describe('OAuth Flow Integration Tests', () => {
366371

367372
const response = await request(app).get('/slack/oauth/callback').query({
368373
code: 'complete-auth-code',
369-
state: 'complete_state_1234567890',
374+
state: generateState(),
370375
});
371376

372377
expect(response.status).toBe(200);
@@ -410,7 +415,7 @@ describe('OAuth Flow Integration Tests', () => {
410415

411416
const response = await request(app).get('/slack/oauth/callback').query({
412417
code: 'partial-auth-code',
413-
state: 'partial_state_1234567890',
418+
state: generateState(),
414419
});
415420

416421
// Should still succeed overall

packages/ai-toolkit-nx-claude/scripts/reset-prerelease-version.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
# Script to reset version for prerelease on next branch
44
# This helps fix the issue where next branch has same version as latest
55

6+
# Source numeric validation helper to guard values that flow into bash arithmetic
7+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8+
VALIDATE_NUMERIC_LIB="$SCRIPT_DIR/../../../.github/scripts/validate-numeric.sh"
9+
if [[ ! -f "$VALIDATE_NUMERIC_LIB" ]]; then
10+
echo "❌ Required helper not found: $VALIDATE_NUMERIC_LIB" >&2
11+
exit 1
12+
fi
13+
source "$VALIDATE_NUMERIC_LIB"
14+
615
PACKAGE_NAME="@uniswap/ai-toolkit-nx-claude"
716
CURRENT_VERSION=$(jq -r '.version' packages/ai-toolkit-nx-claude/package.json)
817

@@ -43,6 +52,7 @@ fi
4352
# Determine target version for next branch
4453
if [[ "$MAJOR.$MINOR.$PATCH" == "$LATEST_MAJOR.$LATEST_MINOR.$LATEST_PATCH" ]]; then
4554
# Same version, need to bump for next
55+
MINOR=$(validate_numeric "$MINOR" "minor") || exit 1
4656
NEW_MINOR=$((MINOR + 1))
4757
TARGET_VERSION="$MAJOR.$NEW_MINOR.0"
4858
else
@@ -59,6 +69,7 @@ if [[ -n "$EXISTING_PRERELEASES" ]]; then
5969
# Extract the prerelease number if it matches our target
6070
if [[ "$EXISTING_PRERELEASES" == "$TARGET_VERSION-next."* ]]; then
6171
PRERELEASE_NUM=${EXISTING_PRERELEASES##*-next.}
72+
PRERELEASE_NUM=$(validate_numeric "$PRERELEASE_NUM" "prerelease number") || exit 1
6273
NEXT_PRERELEASE=$((PRERELEASE_NUM + 1))
6374
else
6475
NEXT_PRERELEASE=0

0 commit comments

Comments
 (0)