Another in the series of redacted-format engagement reports, this one on @slack/oauth, Slack's own official package for building the "Add to Slack" OAuth install flow. Any app that lets a user connect their Slack workspace, from a one-person side project to an enterprise integration, is a plausible consumer of this exact code. That makes the target here specifically the state parameter verification path, the mechanism that's supposed to guarantee the browser completing an OAuth callback is the same one that started it.
Scope and Objective
Target: InstallProvider.handleCallback() and ClearStateStore.verifyStateParam() in packages/oauth/src/install-provider.ts and packages/oauth/src/state-stores/clear-state-store.ts, cloned directly from slackapi/node-slack-sdk.
Objective: read the real CSRF-protection logic end to end, not the README's description of it, and check whether every configuration the package actually ships still enforces session binding on the state parameter. Verification meant extracting the real ClearStateStore class verbatim and running it, unmodified, against a crafted scenario in a disposable directory, never against a live Slack workspace or any third party's deployment.
Findings
legacyStateVerification drops session binding entirely (High)
InstallProvider's default behavior, the code that ships when a developer doesn't touch this option, is correct. handleCallback() reads a state value out of an HttpOnly cookie set on the browser that started the flow, compares it against the state query parameter on the callback, and rejects the request if they don't match:
const stateInBrowserSession: string | undefined = extractCookieValue(req, this.stateCookieName);
if (!stateInBrowserSession || stateInBrowserSession !== stateInQueryString) {
throw new InvalidStateError('The state parameter is not for this browser session.');
}
That's the actual CSRF defense: binding the value to a specific browser's cookie jar means an attacker can't hand a victim a link carrying a state the attacker generated for themselves. But the package also ships a constructor flag, legacyStateVerification, "intended... for some of the existing developers that need time for migration." When it's true, the cookie comparison above is skipped entirely:
if (this.legacyStateVerification) {
// This mode is not enabled by default
this.logger.warn(
'Enabling legacyStateVerification is not recommended as it does not properly work for OAuth CSRF protection...',
);
} else {
const stateInBrowserSession = extractCookieValue(req, this.stateCookieName);
if (!stateInBrowserSession || stateInBrowserSession !== stateInQueryString) {
throw new InvalidStateError('The state parameter is not for this browser session.');
}
}
if (this.stateStore) {
installOptions = await this.stateStore.verifyStateParam(new Date(), stateInQueryString);
}
With legacyStateVerification set, the only remaining check is stateStore.verifyStateParam(), and the package's own default StateStore implementation, ClearStateStore, verifies exactly two things about a state value: that it's a correctly-signed JWT, and that it isn't older than the expiration window (600 seconds by default). Neither check has any concept of which browser the value belongs to. A state the package itself generated for an attacker's own install attempt passes with the same validity as one generated for the victim.