Skip to content

Commit aa74d1e

Browse files
Merge pull request #11 from NeverEndingCode/fix-boot-probe-404
v1.8.4: dual mode would have refused to start against a good core
2 parents 3c816d6 + 285be9e commit aa74d1e

7 files changed

Lines changed: 161 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## v1.8.4
4+
5+
- **`AUTH_MODE=dual` would have refused to start against a correctly-secured
6+
core.** v1.8.3 fixed the wrong-endpoint bug in the preflight but left a second
7+
copy of the same probe in the boot path, still using `/recipe/users/count`
8+
which SuperTokens core 12 does not implement. That guard *throws* on anything
9+
that is not a 401, so the 404 would have been read as "the core is running
10+
without API_KEYS" and stopped the container from booting.
11+
12+
Unlike the preflight, where the bug printed an alarming line, here it would
13+
have blocked the cutover entirely and blamed the operator for a URL this code
14+
got wrong.
15+
16+
The probe now lives in `server/supertokens/coreProbe.js` and is imported by
17+
both callers, so they cannot drift again — asserted by a test. The boot guard
18+
now throws **only** on a confirmed-open core (a known endpoint answering an
19+
unkeyed request with 200); every other outcome warns and lets the boot
20+
proceed.
21+
22+
323
## v1.8.3
424

525
- **`supertokens:check` no longer reports a correctly-secured core as wide

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT"
4444
# only on a pushed vX.Y.Z tag, and docker/metadata-action derives the
4545
# published image's version label from that tag - so this literal only
4646
# affects locally-built images, not what GHCR publishes.
47-
LABEL org.opencontainers.image.version="1.8.3"
47+
LABEL org.opencontainers.image.version="1.8.4"
4848

4949
VOLUME ["/app/data"]
5050
EXPOSE 3000

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rackstack-server",
3-
"version": "1.8.3",
3+
"version": "1.8.4",
44
"private": true,
55
"type": "module",
66
"scripts": {

server/supertokens/coreProbe.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Asking a SuperTokens core whether it requires authentication.
2+
//
3+
// ONE implementation, imported by both the boot-time guard (init.js) and the
4+
// operator preflight (preflight.js). It lives in its own module because it
5+
// previously did not: the same probe was written twice, the path was corrected
6+
// in the preflight, and the copy in the boot path was left behind - where a
7+
// wrong answer does not print a scary line, it stops the container from
8+
// starting. If you add a third caller, import this; do not copy it.
9+
10+
/**
11+
* Endpoints gated by the core's API key, newest path first.
12+
*
13+
* NOT `/hello` - that answers unauthenticated by design as a health check, so
14+
* a 200 there proves nothing about whether the core is locked down.
15+
*
16+
* More than one because the path is tenant-scoped on modern cores
17+
* (`/<tenantId>/users/count`, per supertokens-node's own querier) and was not
18+
* on older ones. v1.8.2 shipped a single guessed path that core 12 does not
19+
* implement, so every probe returned 404.
20+
*/
21+
export const AUTHED_ENDPOINTS = Object.freeze([
22+
'/public/users/count', // cores with multitenancy (the default tenant)
23+
'/recipe/users/count', // older cores
24+
]);
25+
26+
/**
27+
* Probes the first endpoint this core actually implements.
28+
*
29+
* Returns `{ status, path }`. `status` is `null` when every candidate 404s,
30+
* which means "this core exposes no path we know how to ask" - NOT "the core
31+
* answered". That distinction is the entire point of this module: a 404 is
32+
* evidence about our URL, not about the core's authentication, and inferring
33+
* a security verdict from one is how v1.8.2 reported a correctly-secured core
34+
* as running wide open.
35+
*/
36+
export async function probeAuthedEndpoint({
37+
connectionURI, apiKey, fetchImpl = fetch, timeoutMs = 5000,
38+
}) {
39+
const base = connectionURI.replace(/\/$/, '');
40+
const headers = { 'api-version': '3.0' };
41+
if (apiKey) headers['api-key'] = apiKey;
42+
43+
for (const path of AUTHED_ENDPOINTS) {
44+
// eslint-disable-next-line no-await-in-loop
45+
const res = await fetchImpl(`${base}${path}`, {
46+
method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs),
47+
});
48+
if (res.status !== 404) return { status: res.status, path };
49+
}
50+
return { status: null, path: null };
51+
}
52+
53+
/** A refusal, however the core spells it. */
54+
export function isRefused(status) {
55+
return status === 401 || status === 403;
56+
}

server/supertokens/init.js

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import { isSuperTokensEnabled } from '../authMode.js';
3838
import { buildProviders, resolvePublicOrigin } from './providers.js';
3939
import { buildSignInUpOverride } from './mapping.js';
40+
import { probeAuthedEndpoint, isRefused } from './coreProbe.js';
4041

4142
// SuperTokens' own default API base path. It is also why the runbook widens
4243
// the GitHub OAuth registration to /auth: SuperTokens serves its callbacks at
@@ -135,26 +136,25 @@ export function isLoopback(uri) {
135136
/**
136137
* Confirms the core actually refuses unauthenticated callers.
137138
*
138-
* Probes an endpoint that requires an API key when one is configured. A 401
139-
* means the core is closed and all is well; a 200 means it answered a caller
140-
* holding no key at all, which is the state where anyone who can reach it can
141-
* mint a session for any user id.
139+
* Throws ONLY on a confirmed-open core - an endpoint we know exists answering
140+
* an unkeyed request with 200. Every other outcome warns and lets the boot
141+
* proceed, because this guard sits on the startup path and a wrong answer here
142+
* does not print a scary line, it stops the container.
142143
*
143-
* `/hello` is deliberately NOT used - it answers unauthenticated by design as
144-
* a health check, so probing it would prove nothing.
144+
* That distinction was missing in v1.8.2: this probed a single hardcoded path
145+
* that core 12 does not implement, and treated the resulting 404 as proof the
146+
* core was open - so setting AUTH_MODE=dual against a perfectly well-secured
147+
* core would have refused to start, blaming the operator for a URL this code
148+
* got wrong. The probe now lives in ./coreProbe.js and is shared with the
149+
* preflight, so the two cannot drift again.
145150
*/
146151
export async function assertCoreRejectsAnonymous({ connectionURI, hasKey, fetchImpl = fetch }) {
147-
const url = `${connectionURI.replace(/\/$/, '')}/recipe/users/count`;
148-
let response;
152+
let probe;
149153
try {
150-
response = await fetchImpl(url, {
151-
method: 'GET',
152-
headers: { 'api-version': '3.0' },
153-
signal: AbortSignal.timeout(5000),
154-
});
154+
probe = await probeAuthedEndpoint({ connectionURI, fetchImpl });
155155
} catch (e) {
156156
// Unreachable, DNS failure, timeout. Cannot establish anything; the core
157-
// may simply still be starting. Warn rather than refuse - see the caller.
157+
// may simply still be starting. Warn rather than refuse.
158158
console.warn(
159159
`[auth] could not verify that the SuperTokens core at ${connectionURI} requires `
160160
+ `authentication (${e.message}). If it is running without API_KEYS, anyone who can `
@@ -163,16 +163,28 @@ export async function assertCoreRejectsAnonymous({ connectionURI, hasKey, fetchI
163163
return 'unverified';
164164
}
165165

166-
if (response.status === 401) return 'closed';
166+
if (isRefused(probe.status)) return 'closed';
167167

168-
throw new Error(
169-
`The SuperTokens core at ${connectionURI} answered an unauthenticated request with `
170-
+ `HTTP ${response.status}, which means it is running without API_KEYS. Anyone who can `
171-
+ 'reach it can mint a session for any user id, including every value in SUPER_ADMIN_IDS, '
172-
+ 'without any request reaching RackStack. Set API_KEYS on the core to the same value as '
173-
+ `SUPERTOKENS_API_KEY here${hasKey ? '' : ' (which is also unset)'}, and do not publish `
174-
+ 'its port.',
168+
if (probe.status === 200) {
169+
throw new Error(
170+
`The SuperTokens core at ${connectionURI} answered an unauthenticated request to `
171+
+ `${probe.path} with HTTP 200, which means it is running without API_KEYS. Anyone who `
172+
+ 'can reach it can mint a session for any user id, including every value in '
173+
+ 'SUPER_ADMIN_IDS, without any request reaching RackStack. Set API_KEYS on the core to '
174+
+ `the same value as SUPERTOKENS_API_KEY here${hasKey ? '' : ' (which is also unset)'}, `
175+
+ 'and do not publish its port.',
176+
);
177+
}
178+
179+
// A 404 from every candidate, or any other unexpected status, says our URL
180+
// is wrong for this core version - not that the core is open. Refusing to
181+
// boot on that would be punishing the operator for our own mistake.
182+
console.warn(
183+
`[auth] could not verify that the SuperTokens core at ${connectionURI} requires `
184+
+ `authentication (${probe.status === null ? 'no known endpoint answered' : `unexpected HTTP ${probe.status}`}). `
185+
+ 'This is NOT evidence that it is open, but do check it by hand.',
175186
);
187+
return 'unverified';
176188
}
177189

178190
/**

server/supertokens/preflight.js

Lines changed: 3 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import { buildProviders, resolvePublicOrigin, PROVIDER_IDS } from './providers.js';
1818
import { isLoopback } from './init.js';
19+
import { AUTHED_ENDPOINTS, probeAuthedEndpoint, isRefused } from './coreProbe.js';
1920

2021
const PASS = 'PASS';
2122
const FAIL = 'FAIL';
@@ -26,48 +27,12 @@ function result(status, name, detail) {
2627
return { status, name, detail };
2728
}
2829

29-
/**
30-
* Endpoints that require an API key when one is configured, newest path first.
31-
*
32-
* NOT `/hello` - that answers unauthenticated by design as a health check, so a
33-
* 200 there proves nothing about whether the core is locked down.
34-
*
35-
* More than one, because the path is tenant-scoped on modern cores
36-
* (`/<tenantId>/users/count`, per supertokens-node's own querier) and was not
37-
* on older ones. The first version of this shipped a single guessed path,
38-
* `/recipe/users/count`, which does not exist on core 12 - so every probe came
39-
* back 404 and the check reported a correctly-locked-down core as running wide
40-
* open. See the 404 handling below: that false alarm is the reason this is a
41-
* list and not a constant.
42-
*/
43-
const AUTHED_ENDPOINTS = Object.freeze([
44-
'/public/users/count', // core with multitenancy (the default tenant)
45-
'/recipe/users/count', // older cores
46-
]);
47-
4830
async function probe(url, { apiKey, fetchImpl, timeoutMs = 5000 }) {
4931
const headers = { 'api-version': '3.0' };
5032
if (apiKey) headers['api-key'] = apiKey;
5133
return fetchImpl(url, { method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs) });
5234
}
5335

54-
/**
55-
* Probes the first endpoint this core actually implements.
56-
*
57-
* Returns `{ status, path }`, or `{ status: null }` when every candidate 404s -
58-
* which means "this core does not expose any path we know how to ask", NOT
59-
* "the core answered". The distinction is the whole point: a 404 is evidence
60-
* about our URL, not about the core's authentication.
61-
*/
62-
async function probeAuthedEndpoint({ connectionURI, apiKey, fetchImpl }) {
63-
for (const path of AUTHED_ENDPOINTS) {
64-
// eslint-disable-next-line no-await-in-loop
65-
const res = await probe(`${connectionURI}${path}`, { apiKey, fetchImpl });
66-
if (res.status !== 404) return { status: res.status, path };
67-
}
68-
return { status: null, path: null };
69-
}
70-
7136
/**
7237
* Runs every deployment check and returns the results.
7338
*
@@ -158,7 +123,7 @@ export async function runPreflight({
158123
// is exactly why it is checked here rather than left to be noticed.
159124
try {
160125
const anon = await probeAuthedEndpoint({ connectionURI, fetchImpl });
161-
if (anon.status === 401 || anon.status === 403) {
126+
if (isRefused(anon.status)) {
162127
checks.push(result(PASS, 'core requires authentication', `anonymous request rejected (${anon.status})`));
163128
} else if (anon.status === 200) {
164129
checks.push(result(
@@ -239,7 +204,7 @@ export async function runPreflight({
239204
const authed = await probeAuthedEndpoint({ connectionURI, apiKey, fetchImpl });
240205
if (authed.status === 200) {
241206
checks.push(result(PASS, 'SUPERTOKENS_API_KEY', 'accepted by the core'));
242-
} else if (authed.status === 401 || authed.status === 403) {
207+
} else if (isRefused(authed.status)) {
243208
checks.push(result(
244209
FAIL, 'SUPERTOKENS_API_KEY',
245210
`The core rejected it (HTTP ${authed.status}). It must be byte-identical to a value in `

tests/supertokens.security.test.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,52 @@ describe('rejectRawOAuthTokens (authentication bypass guard)', () => {
108108
fetchImpl: async (url) => { probed = url; return { status: 401 }; },
109109
});
110110
expect(probed).not.toContain('/hello');
111-
expect(probed).toContain('/recipe/');
111+
// A key-gated endpoint. The exact path is version-dependent, so assert the
112+
// property rather than a literal - the literal is what broke in v1.8.2.
113+
expect(probed).toContain('users/count');
114+
});
115+
116+
it('BOOTS against a core whose endpoints all 404, rather than calling it open', async () => {
117+
// The v1.8.3 near-miss. The preflight's 404 handling was fixed, but this
118+
// guard kept its own copy of the probe with the old hardcoded path - and
119+
// unlike the preflight, a wrong answer here does not print a scary line,
120+
// it stops the container from starting.
121+
//
122+
// So on core 12 (which does not implement /recipe/users/count) setting
123+
// AUTH_MODE=dual against a perfectly well-secured core would have refused
124+
// to boot, blaming the operator for a URL this code got wrong. The probe
125+
// now lives in coreProbe.js and is shared, so the two cannot drift again.
126+
const { assertCoreRejectsAnonymous } = await import('../server/supertokens/init.js');
127+
const noKnownEndpoint = async () => ({ status: 404 });
128+
129+
await expect(assertCoreRejectsAnonymous({
130+
connectionURI: 'http://core:3567', hasKey: true, fetchImpl: noKnownEndpoint,
131+
})).resolves.toBe('unverified');
132+
});
133+
134+
it('finds the tenant-scoped path a modern core actually implements', async () => {
135+
const { assertCoreRejectsAnonymous } = await import('../server/supertokens/init.js');
136+
const core12 = async (url) => (url.endsWith('/public/users/count')
137+
? { status: 401 }
138+
: { status: 404 });
139+
140+
await expect(assertCoreRejectsAnonymous({
141+
connectionURI: 'http://core:3567', hasKey: true, fetchImpl: core12,
142+
})).resolves.toBe('closed');
143+
});
144+
145+
it('shares one probe implementation with the preflight', async () => {
146+
// The bug was duplication, so this asserts the de-duplication rather than
147+
// the behaviour: both callers must import from coreProbe.js. A future
148+
// caller that re-copies the paths reintroduces exactly this class of bug.
149+
const init = readFileSync(new URL('../server/supertokens/init.js', import.meta.url), 'utf8');
150+
const preflight = readFileSync(new URL('../server/supertokens/preflight.js', import.meta.url), 'utf8');
151+
152+
expect(init).toMatch(/from '\.\/coreProbe\.js'/);
153+
expect(preflight).toMatch(/from '\.\/coreProbe\.js'/);
154+
// Neither may hardcode a probe path of its own.
155+
expect(init).not.toMatch(/users\/count/);
156+
expect(preflight).not.toMatch(/'\/(public|recipe)\/users\/count'/);
112157
});
113158

114159
it('refuses to boot against a core too old for this SDK', async () => {

0 commit comments

Comments
 (0)