Files
RSSHub/lib/middleware/access-control.test.ts

117 lines
5.5 KiB
TypeScript

import { Hono } from 'hono';
import { afterEach, describe, expect, it, vi } from 'vitest';
import md5 from '@/utils/md5';
import type { WeiboOAuthState } from '@/utils/weibo-oauth';
process.env.NODE_NAME = 'mock';
async function checkBlock(response) {
expect(response.status).toBe(403);
expect(await response.text()).toMatch(/Access denied\./);
}
afterEach(() => {
delete process.env.ACCESS_KEY;
vi.resetModules();
});
describe('access-control', () => {
it('access key', async () => {
const key = '1L0veRSSHub';
const code = md5('/test/2' + key);
process.env.ACCESS_KEY = key;
const app = (await import('@/app')).default;
const response01 = await app.request('/');
expect(response01.status).toBe(200);
const response02 = await app.request('/robots.txt');
expect(response02.status).toBe(404);
// no key/code
const response21 = await app.request('/test/2');
await checkBlock(response21);
// wrong key/code
const response321 = await app.request(`/test/2?key=wrong+${key}`);
await checkBlock(response321);
const response322 = await app.request(`/test/2?code=wrong+${code}`);
await checkBlock(response322);
// right key/code
const response331 = await app.request(`/test/2?key=${key}`);
expect(response331.status).toBe(200);
const response332 = await app.request(`/test/2?code=${code}`);
expect(response332.status).toBe(200);
});
});
const setupProtectedWeiboOAuth = async () => {
process.env.ACCESS_KEY = 'fake-test-key';
const { config } = await import('@/config');
const cache = (await import('@/utils/cache')).default;
const { default: accessControl } = await import('@/middleware/access-control');
const { createWeiboOAuthState } = await import('@/utils/weibo-oauth');
const app = new Hono<{ Variables: { weiboOAuthState: WeiboOAuthState } }>();
app.onError((error, ctx) => ctx.text(error.message, error.name === 'RejectError' ? 403 : 500));
app.use('*', accessControl);
app.get('/authorize', async (ctx) => ctx.text(await createWeiboOAuthState(ctx, '2', 'displayVideo=1')));
app.get('/weibo/timeline/0', (ctx) => {
const state = ctx.get('weiboOAuthState');
return state ? ctx.json(state) : ctx.text('initial authorization');
});
return { app, cache, config };
};
describe('protected Weibo OAuth', () => {
it('rejects anonymous authorization and unknown callback states', async () => {
const { app } = await setupProtectedWeiboOAuth();
expect((await app.request('/authorize')).status).toBe(403);
expect((await app.request('/weibo/timeline/0?code=fake-oauth-code&state=not-a-state')).status).toBe(403);
});
it('allows a valid access signature to initiate authorization on the callback path', async () => {
const { app, config } = await setupProtectedWeiboOAuth();
const response = await app.request(`/weibo/timeline/0?code=${md5('/weibo/timeline/0' + config.accessKey)}`);
expect(response.status).toBe(200);
expect(await response.text()).toBe('initial authorization');
});
it('consumes callback state once, including concurrent callbacks', async () => {
const { app } = await setupProtectedWeiboOAuth();
const authorization = await app.request('/authorize?key=fake-test-key');
expect(authorization.status).toBe(200);
const state = await authorization.text();
expect(state).not.toContain('fake-test-key');
const responses = await Promise.all([app.request(`/weibo/timeline/0?code=fake-oauth-code&state=${state}`), app.request(`/weibo/timeline/0?code=fake-oauth-code&state=${state}`)]);
expect(responses.map((response) => response.status).toSorted((left, right) => left - right)).toEqual([200, 403]);
const response = responses.find((response) => response.status === 200)!;
expect(await response.json()).toMatchObject({ feature: '2', routeParams: 'displayVideo=1' });
expect((await app.request(`/weibo/timeline/0?code=fake-oauth-code&state=${state}`)).status).toBe(403);
});
it('rejects expired states and states issued before access-key rotation', async () => {
const { app, cache, config } = await setupProtectedWeiboOAuth();
const expiredState = await (await app.request('/authorize?key=fake-test-key')).text();
const key = `weibo:oauth:state:${expiredState}`;
const stored = JSON.parse((await cache.globalCache.get(key))!);
await cache.globalCache.set(key, { ...stored, expiresAt: Date.now() - 1 }, 600);
expect((await app.request(`/weibo/timeline/0?code=fake-oauth-code&state=${expiredState}`)).status).toBe(403);
const previousState = await (await app.request('/authorize?key=fake-test-key')).text();
config.accessKey = 'rotated-fake-test-key';
expect((await app.request(`/weibo/timeline/0?code=fake-oauth-code&state=${previousState}`)).status).toBe(403);
});
it('fails closed when the cache cannot guarantee atomic claims', async () => {
const { app, cache } = await setupProtectedWeiboOAuth();
cache.globalCache.supportsAtomicClaims = false;
const response = await app.request('/authorize?key=fake-test-key');
expect(response.status).toBe(500);
expect(await response.text()).toContain('memory or Redis cache');
expect((await app.request('/weibo/timeline/0?code=fake-oauth-code&state=00000000-0000-0000-0000-000000000000')).status).toBe(403);
});
});