Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions packages/stack-shared/src/interface/client-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateSecureRandomString } from '../utils/crypto';
import { StackAssertionError, throwErr } from '../utils/errors';
import { globalVar } from '../utils/globals';
import { HTTP_METHODS, HttpMethod } from '../utils/http';
import { shouldAllowInsecureRequest } from '../utils/http-security';
import { ReadonlyJson } from '../utils/json';
import { filterUndefined, filterUndefinedOrNull } from '../utils/objects';
import { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON } from '../utils/passkey';
Expand Down Expand Up @@ -38,6 +39,12 @@ export type ClientInterfaceOptions = {
extraRequestHeaders: Record<string, string>,
projectId: string,
prepareRequest?: () => Promise<void>,
/**
* Allows HTTP (non-HTTPS) requests to non-localhost servers.
* WARNING: Only use this for testing environments. Never enable in production.
* @default false
*/
dangerouslyAllowInsecureHttp?: boolean,
Comment on lines +42 to +47
Copy link
Contributor

@N2D4 N2D4 Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not that dangerous. it would still require us to have an HTTP base url... which we'd never do in production

keep in mind, tokenEndpoint is controlled by us, not an attacker

Suggested change
/**
* Allows HTTP (non-HTTPS) requests to non-localhost servers.
* WARNING: Only use this for testing environments. Never enable in production.
* @default false
*/
dangerouslyAllowInsecureHttp?: boolean,

} & ({
publishableClientKey: string,
} | {
Expand Down Expand Up @@ -165,8 +172,7 @@ export class StackClientInterface {
};

const clientAuthentication = oauth.ClientSecretPost(this.options.publishableClientKey);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const allowInsecure = (process.env.NODE_ENV?.includes("dev") || process.env.NODE_ENV === 'test') && tokenEndpoint.startsWith('http://');
const allowInsecure = shouldAllowInsecureRequest(tokenEndpoint, this.options.dangerouslyAllowInsecureHttp);

const response = await this._networkRetryException(async () => {
const rawResponse = await oauth.refreshTokenGrantRequest(
Expand Down Expand Up @@ -1041,9 +1047,7 @@ export class StackClientInterface {
client_secret: this.options.publishableClientKey,
};
const clientAuthentication = oauth.ClientSecretPost(this.options.publishableClientKey);
// Allow insecure HTTP requests only in test environment (for localhost testing)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const allowInsecure = (process.env.NODE_ENV?.includes("dev") || process.env.NODE_ENV === 'test') && tokenEndpoint.startsWith('http://');
const allowInsecure = shouldAllowInsecureRequest(tokenEndpoint, this.options.dangerouslyAllowInsecureHttp);

let params: URLSearchParams;
try {
Expand Down
96 changes: 96 additions & 0 deletions packages/stack-shared/src/utils/http-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { shouldAllowInsecureRequest } from './http-security';

describe('shouldAllowInsecureRequest', () => {
describe('localhost HTTP (always allowed)', () => {
it('should allow http://localhost', () => {
expect(shouldAllowInsecureRequest('http://localhost')).toBe(true);
});

it('should allow http://localhost with port', () => {
expect(shouldAllowInsecureRequest('http://localhost:8080')).toBe(true);
});

it('should allow http://localhost with path', () => {
expect(shouldAllowInsecureRequest('http://localhost/api/token')).toBe(true);
});

it('should allow http://localhost with port and path', () => {
expect(shouldAllowInsecureRequest('http://localhost:8080/api/token')).toBe(true);
});

it('should allow http://127.0.0.1', () => {
expect(shouldAllowInsecureRequest('http://127.0.0.1')).toBe(true);
});

it('should allow http://127.0.0.1 with port', () => {
expect(shouldAllowInsecureRequest('http://127.0.0.1:8080')).toBe(true);
});
});

describe('localhost HTTPS (allowed, no insecure flag needed)', () => {
it('should not need insecure flag for https://localhost', () => {
expect(shouldAllowInsecureRequest('https://localhost')).toBe(false);
});

it('should not need insecure flag for https://127.0.0.1', () => {
expect(shouldAllowInsecureRequest('https://127.0.0.1:8080')).toBe(false);
});
});

describe('non-localhost HTTP (blocked by default)', () => {
it('should block http://example.com by default', () => {
expect(shouldAllowInsecureRequest('http://example.com')).toBe(false);
});

it('should block http://test.internal:8080 by default', () => {
expect(shouldAllowInsecureRequest('http://test.internal:8080')).toBe(false);
});

it('should block http://192.168.1.1 by default (not 127.0.0.1)', () => {
expect(shouldAllowInsecureRequest('http://192.168.1.1')).toBe(false);
});
});

describe('non-localhost HTTP with dangerouslyAllowInsecureHttp', () => {
it('should allow http://example.com when opted in', () => {
expect(shouldAllowInsecureRequest('http://example.com', true)).toBe(true);
});

it('should allow http://test.internal:8080 when opted in', () => {
expect(shouldAllowInsecureRequest('http://test.internal:8080', true)).toBe(true);
});

it('should allow http://192.168.1.1 when opted in', () => {
expect(shouldAllowInsecureRequest('http://192.168.1.1:8080/api', true)).toBe(true);
});
});

describe('HTTPS (never needs insecure flag)', () => {
it('should not need insecure flag for https://example.com', () => {
expect(shouldAllowInsecureRequest('https://example.com')).toBe(false);
});

it('should not need insecure flag for https://example.com even with opt-in', () => {
expect(shouldAllowInsecureRequest('https://example.com', true)).toBe(false);
});
});

describe('edge cases', () => {
it('should not match localhost in subdomain (http://localhost.evil.com)', () => {
expect(shouldAllowInsecureRequest('http://localhost.evil.com')).toBe(false);
});

it('should not match 127.0.0.1 prefix (http://127.0.0.100)', () => {
expect(shouldAllowInsecureRequest('http://127.0.0.100')).toBe(false);
});

it('should handle undefined dangerouslyAllowInsecureHttp', () => {
expect(shouldAllowInsecureRequest('http://example.com', undefined)).toBe(false);
});

it('should handle false dangerouslyAllowInsecureHttp', () => {
expect(shouldAllowInsecureRequest('http://example.com', false)).toBe(false);
});
});
});
10 changes: 10 additions & 0 deletions packages/stack-shared/src/utils/http-security.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Determines if insecure HTTP requests should be allowed for a given endpoint.
* - Localhost HTTP is always allowed (for local development)
* - Non-localhost HTTP requires explicit opt-in via dangerouslyAllowInsecureHttp
* - HTTPS never needs this flag
*/
export function shouldAllowInsecureRequest(endpoint: string, dangerouslyAllowInsecureHttp?: boolean): boolean {
const isLocalhostHttp = /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(endpoint);
return isLocalhostHttp || (!!dangerouslyAllowInsecureHttp && endpoint.startsWith('http://'));
}
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
projectId,
clientVersion,
publishableClientKey: resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey(),
dangerouslyAllowInsecureHttp: resolvedOptions.dangerouslyAllowInsecureHttp ?? false,
prepareRequest: async () => {
await cookies?.(); // THIS_LINE_PLATFORM next
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export type StackClientAppConstructorOptions<HasTokenStore extends boolean, Proj
* the app is never used or disposed of immediately. To disable this behavior, set this option to true.
*/
noAutomaticPrefetch?: boolean,

/**
* Allows HTTP (non-HTTPS) requests to non-localhost servers.
* WARNING: Only use this for testing environments. Never enable in production.
* @default false
*/
dangerouslyAllowInsecureHttp?: boolean,
} & (
{ tokenStore: TokenStoreInit<HasTokenStore> } | { tokenStore?: undefined, inheritsFrom: StackClientApp<HasTokenStore, any> }
) & (
Expand Down
Loading