161 lines
6.1 KiB
JavaScript
161 lines
6.1 KiB
JavaScript
// internalAuth middleware (Session 10) — tests both the new short-form
|
|
// header (x-internal-key) and the legacy long form
|
|
// (X-VYNDR-Internal-Key), the timing-safe compare, and the optional
|
|
// loopback restriction.
|
|
|
|
const { requireInternalAuth, __internals } = require('../../src/middleware/internalAuth');
|
|
|
|
function fakeReqRes({ headers = {}, ip = '127.0.0.1' } = {}) {
|
|
const req = {
|
|
headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
|
|
ip,
|
|
socket: { remoteAddress: ip },
|
|
get(name) {
|
|
return this.headers[name.toLowerCase()];
|
|
},
|
|
};
|
|
const res = {
|
|
statusCode: null,
|
|
body: null,
|
|
status(code) { this.statusCode = code; return this; },
|
|
json(body) { this.body = body; return this; },
|
|
};
|
|
return { req, res };
|
|
}
|
|
|
|
function run(mw, req, res) {
|
|
return new Promise((resolve) => {
|
|
mw(req, res, () => resolve('next'));
|
|
if (res.statusCode != null) resolve('blocked');
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
delete process.env.VYNDR_INTERNAL_KEY;
|
|
});
|
|
|
|
describe('requireInternalAuth', () => {
|
|
describe('configuration guard', () => {
|
|
test('returns 503 when VYNDR_INTERNAL_KEY env var is unset', async () => {
|
|
const mw = requireInternalAuth();
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'anything' } });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('blocked');
|
|
expect(res.statusCode).toBe(503);
|
|
expect(res.body.error).toMatch(/not configured/);
|
|
});
|
|
});
|
|
|
|
describe('header compatibility', () => {
|
|
beforeEach(() => { process.env.VYNDR_INTERNAL_KEY = 'real-key-12345'; });
|
|
|
|
test('accepts the new short-form header (x-internal-key)', async () => {
|
|
const mw = requireInternalAuth();
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' } });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('next');
|
|
});
|
|
|
|
test('accepts the legacy long-form header (X-VYNDR-Internal-Key)', async () => {
|
|
const mw = requireInternalAuth();
|
|
const { req, res } = fakeReqRes({ headers: { 'x-vyndr-internal-key': 'real-key-12345' } });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('next');
|
|
});
|
|
|
|
test('returns 401 on missing header', async () => {
|
|
const mw = requireInternalAuth();
|
|
const { req, res } = fakeReqRes({});
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('blocked');
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
test('returns 401 on wrong key (timing-safe)', async () => {
|
|
const mw = requireInternalAuth();
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'wrong-key' } });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('blocked');
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
test('returns 401 even when prefix matches but suffix differs', async () => {
|
|
// Guards against early-exit string compare leaking timing info.
|
|
const mw = requireInternalAuth();
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-1234X' } });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('blocked');
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
test('returns 401 on length mismatch (avoids any timingSafeEqual throw)', async () => {
|
|
const mw = requireInternalAuth();
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'short' } });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('blocked');
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('loopbackOnly option', () => {
|
|
beforeEach(() => { process.env.VYNDR_INTERNAL_KEY = 'real-key-12345'; });
|
|
|
|
test('passes from 127.0.0.1', async () => {
|
|
const mw = requireInternalAuth({ loopbackOnly: true });
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '127.0.0.1' });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('next');
|
|
});
|
|
|
|
test('passes from IPv6 loopback', async () => {
|
|
const mw = requireInternalAuth({ loopbackOnly: true });
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '::1' });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('next');
|
|
});
|
|
|
|
test('passes from IPv4-mapped IPv6 loopback', async () => {
|
|
const mw = requireInternalAuth({ loopbackOnly: true });
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '::ffff:127.0.0.1' });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('next');
|
|
});
|
|
|
|
test('returns 403 from off-host IP', async () => {
|
|
const mw = requireInternalAuth({ loopbackOnly: true });
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '10.0.0.42' });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('blocked');
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body.error).toMatch(/Origin not permitted/);
|
|
});
|
|
|
|
test('WITHOUT loopbackOnly, off-host IPs are accepted (n8n case)', async () => {
|
|
const mw = requireInternalAuth({ loopbackOnly: false });
|
|
const { req, res } = fakeReqRes({ headers: { 'x-internal-key': 'real-key-12345' }, ip: '172.18.0.3' });
|
|
const out = await run(mw, req, res);
|
|
expect(out).toBe('next');
|
|
});
|
|
});
|
|
|
|
describe('timingSafeStringEqual', () => {
|
|
const { timingSafeStringEqual } = __internals;
|
|
|
|
test('equal strings → true', () => {
|
|
expect(timingSafeStringEqual('abc123', 'abc123')).toBe(true);
|
|
});
|
|
test('different strings same length → false', () => {
|
|
expect(timingSafeStringEqual('abc123', 'abc124')).toBe(false);
|
|
});
|
|
test('different lengths → false (does not throw)', () => {
|
|
expect(() => timingSafeStringEqual('a', 'abc')).not.toThrow();
|
|
expect(timingSafeStringEqual('a', 'abc')).toBe(false);
|
|
});
|
|
test('non-string inputs → false', () => {
|
|
expect(timingSafeStringEqual(null, 'a')).toBe(false);
|
|
expect(timingSafeStringEqual('a', undefined)).toBe(false);
|
|
expect(timingSafeStringEqual(123, '123')).toBe(false);
|
|
});
|
|
});
|
|
});
|