mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Loopback binds now warn and start when .env or .env.local exists but cannot be read; non-loopback binds keep the fail-closed error. Help text notes that hostnames resolve to IPv4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
5.1 KiB
TypeScript
126 lines
5.1 KiB
TypeScript
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
assertSecureEvalServerBinding,
|
|
isEvalServerBearerAuthorized,
|
|
isEvalServerLoopbackHost,
|
|
resolveEvalServerAuthToken,
|
|
resolveEvalServerAuthTokenForHost,
|
|
resolveEvalServerBindHost,
|
|
} from '../../src/cli/eval-server.js';
|
|
|
|
describe('eval-server bearer authentication', () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('resolves a trimmed token and treats blank values as absent', () => {
|
|
expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: ' secret-value ' })).toBe(
|
|
'secret-value',
|
|
);
|
|
expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: '' })).toBeUndefined();
|
|
expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: ' ' })).toBeUndefined();
|
|
});
|
|
|
|
it('loads .env.local before .env while preserving explicit shell values', () => {
|
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-eval-auth-'));
|
|
tempDirs.push(cwd);
|
|
writeFileSync(path.join(cwd, '.env'), 'GITNEXUS_AUTH_TOKEN=from-env\n');
|
|
writeFileSync(path.join(cwd, '.env.local'), 'GITNEXUS_AUTH_TOKEN=from-local\n');
|
|
|
|
expect(resolveEvalServerAuthToken({}, cwd)).toBe('from-local');
|
|
expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: 'from-shell' }, cwd)).toBe(
|
|
'from-shell',
|
|
);
|
|
expect(resolveEvalServerAuthToken({ GITNEXUS_AUTH_TOKEN: '' }, cwd)).toBeUndefined();
|
|
});
|
|
|
|
it('defers an unreadable env file on loopback and stays fail-closed for remote binds', () => {
|
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-eval-auth-'));
|
|
tempDirs.push(cwd);
|
|
mkdirSync(path.join(cwd, '.env.local'));
|
|
|
|
const loopback = resolveEvalServerAuthTokenForHost('127.0.0.1', {}, cwd);
|
|
expect(loopback.token).toBeUndefined();
|
|
expect(loopback.warning).toMatch(/Unable to read eval-server authentication/i);
|
|
expect(loopback.warning).toMatch(/loopback/i);
|
|
|
|
expect(() => resolveEvalServerAuthTokenForHost('0.0.0.0', {}, cwd)).toThrow(
|
|
/Unable to read eval-server authentication/i,
|
|
);
|
|
});
|
|
|
|
it('resolves the token for a host without touching files when the shell provides it', () => {
|
|
const resolved = resolveEvalServerAuthTokenForHost('0.0.0.0', {
|
|
GITNEXUS_AUTH_TOKEN: 'from-shell',
|
|
});
|
|
expect(resolved).toEqual({ token: 'from-shell' });
|
|
});
|
|
|
|
it('falls back to .env when .env.local is absent', () => {
|
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-eval-auth-'));
|
|
tempDirs.push(cwd);
|
|
writeFileSync(path.join(cwd, '.env'), 'GITNEXUS_AUTH_TOKEN="from env"\n');
|
|
|
|
expect(resolveEvalServerAuthToken({}, cwd)).toBe('from env');
|
|
});
|
|
|
|
it('resolves DNS bind names to the concrete IPv4 used for the security decision', async () => {
|
|
const resolveHostname = async (hostname: string) => {
|
|
expect(hostname).toBe('devbox.local');
|
|
return '192.168.1.50';
|
|
};
|
|
|
|
await expect(resolveEvalServerBindHost('devbox.local', resolveHostname)).resolves.toBe(
|
|
'192.168.1.50',
|
|
);
|
|
await expect(resolveEvalServerBindHost('devbox.local', async () => '::1')).resolves.toBeNull();
|
|
await expect(resolveEvalServerBindHost('not a hostname', resolveHostname)).resolves.toBeNull();
|
|
});
|
|
|
|
it('preserves literal IP addresses without a DNS lookup', async () => {
|
|
let lookupCalled = false;
|
|
await expect(
|
|
resolveEvalServerBindHost('10.0.0.2', async () => {
|
|
lookupCalled = true;
|
|
return '127.0.0.1';
|
|
}),
|
|
).resolves.toBe('10.0.0.2');
|
|
expect(lookupCalled).toBe(false);
|
|
});
|
|
|
|
it.each(['127.0.0.1', '127.0.0.2', 'localhost', '::1'])('classifies %s as loopback', (host) => {
|
|
expect(isEvalServerLoopbackHost(host)).toBe(true);
|
|
});
|
|
|
|
it.each(['0.0.0.0', '::', '192.168.1.50', '2001:db8::1', 'localhost.evil.test'])(
|
|
'classifies %s as non-loopback',
|
|
(host) => {
|
|
expect(isEvalServerLoopbackHost(host)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('allows loopback without a token and requires one for non-loopback binds', () => {
|
|
expect(() => assertSecureEvalServerBinding('127.0.0.1', undefined)).not.toThrow();
|
|
expect(() => assertSecureEvalServerBinding('::1', undefined)).not.toThrow();
|
|
expect(() => assertSecureEvalServerBinding('0.0.0.0', 'secret-value')).not.toThrow();
|
|
expect(() => assertSecureEvalServerBinding('192.168.1.50', undefined)).toThrow(
|
|
/non-loopback.*GITNEXUS_AUTH_TOKEN/i,
|
|
);
|
|
});
|
|
|
|
it('accepts only the exact Bearer header when a token is configured', () => {
|
|
const token = 'secret-value';
|
|
expect(isEvalServerBearerAuthorized(undefined, undefined)).toBe(true);
|
|
expect(isEvalServerBearerAuthorized(`Bearer ${token}`, token)).toBe(true);
|
|
expect(isEvalServerBearerAuthorized(undefined, token)).toBe(false);
|
|
expect(isEvalServerBearerAuthorized(`Bearer wrong`, token)).toBe(false);
|
|
expect(isEvalServerBearerAuthorized(token, token)).toBe(false);
|
|
expect(isEvalServerBearerAuthorized(`bearer ${token}`, token)).toBe(false);
|
|
expect(isEvalServerBearerAuthorized([`Bearer ${token}`], token)).toBe(false);
|
|
});
|
|
});
|