diff --git a/src/client.js b/src/client.js index 718d15c..87709c6 100644 --- a/src/client.js +++ b/src/client.js @@ -217,7 +217,7 @@ module.exports = class Client { * @prop {string} [signature] User auth token signature (in 'sessionid_sign' cookie) * @prop {boolean} [DEBUG] Enable debug mode * @prop {'data' | 'prodata' | 'widgetdata'} [server] Server type - * @prop {string} [location] Auth page location (For france: https://fr.tradingview.com/) + * @prop {string} [location] Auth page location (For France: https://fr.tradingview.com/chart/) * @prop {Object} [headers] Custom WebSocket headers */ @@ -246,7 +246,7 @@ module.exports = class Client { misc.getUser( clientOptions.token, clientOptions.signature ? clientOptions.signature : '', - clientOptions.location ? clientOptions.location : 'https://tradingview.com', + clientOptions.location || undefined, ).then((user) => { this.#sendQueue.unshift(protocol.formatWSPacket({ m: 'set_auth_token', diff --git a/src/miscRequests.js b/src/miscRequests.js index a9707f2..4950663 100644 --- a/src/miscRequests.js +++ b/src/miscRequests.js @@ -426,15 +426,15 @@ module.exports = { * @function getUser * @param {string} session User 'sessionid' cookie * @param {string} [signature] User 'sessionid_sign' cookie - * @param {string} [location] Auth page location (For france: https://fr.tradingview.com/) + * @param {string} [location] Auth page location (For France: https://fr.tradingview.com/chart/) * @returns {Promise} Token */ - async getUser(session, signature = '', location = 'https://www.tradingview.com/', redirectCount = 0) { + async getUser(session, signature = '', location = 'https://www.tradingview.com/chart/', redirectCount = 0) { if (redirectCount > 5) { throw new Error('Too many redirects - possible WAF or geo-restriction'); } - const { data, headers } = await axios.get(location, { + const { data, headers, status } = await axios.get(location, { headers: { cookie: genAuthCookies(session, signature), }, @@ -464,8 +464,15 @@ module.exports = { }; } - if (headers.location !== location) { - return this.getUser(session, signature, headers.location, redirectCount + 1); + if (status >= 300 && status < 400 && headers.location) { + const redirect = new URL(headers.location, location); + if (redirect.protocol !== 'https:' || ( + redirect.hostname !== 'tradingview.com' + && !redirect.hostname.endsWith('.tradingview.com') + )) { + throw new Error('Unexpected authentication redirect destination'); + } + return this.getUser(session, signature, redirect.href, redirectCount + 1); } throw new Error('Wrong or expired sessionid/signature'); diff --git a/tests/client-auth.test.ts b/tests/client-auth.test.ts new file mode 100644 index 0000000..476b7d2 --- /dev/null +++ b/tests/client-auth.test.ts @@ -0,0 +1,85 @@ +import { EventEmitter } from 'events'; +import { Module } from 'module'; +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; + +const axios = require('axios'); + +const chartURL = 'https://www.tradingview.com/chart/'; + +describe('Client authentication location', () => { + const wsPath = require.resolve('ws'); + const clientPath = require.resolve('../src/client'); + let originalWS; + let originalClient; + let socket; + let get; + let Client; + + beforeEach(() => { + originalWS = require.cache[wsPath]; + originalClient = require.cache[clientPath]; + class FakeWebSocket extends EventEmitter { + OPEN = 1; + + readyState = 1; + + send = vi.fn(); + + close = vi.fn(); + + constructor() { + super(); + socket = this; + } + } + const wsModule = new Module(wsPath); + wsModule.exports = FakeWebSocket; + wsModule.loaded = true; + require.cache[wsPath] = wsModule; + delete require.cache[clientPath]; + // Load Client after replacing its CommonJS WebSocket dependency. + // eslint-disable-next-line global-require + Client = require('../src/client'); + get = vi.spyOn(axios, 'get').mockImplementation(async (url) => ({ + status: 200, + data: url.endsWith('/chart/') + ? '{"id":123,"username":"test_user","auth_token":"test_auth_token"}' + : 'homepage without token', + headers: {}, + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalWS) require.cache[wsPath] = originalWS; + else delete require.cache[wsPath]; + if (originalClient) require.cache[clientPath] = originalClient; + else delete require.cache[clientPath]; + }); + + const locations = [undefined, '', 'https://fr.tradingview.com/chart/']; + it.each(locations)('authenticates with location %s', async (location) => { + const client = new Client({ token: 'fake_session', signature: 'fake_signature', location }); + const onError = vi.fn(); + client.onError(onError); + await new Promise((resolve) => { setImmediate(resolve); }); + + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledWith(location || chartURL, expect.any(Object)); + expect(client.isLogged).toBe(true); + expect(socket.send).toHaveBeenCalledWith(expect.stringContaining( + '"m":"set_auth_token","p":["test_auth_token"]', + )); + expect(onError).not.toHaveBeenCalled(); + await client.end(); + }); + + it('does not request account information for a public client', async () => { + const client = new Client(); + expect(get).not.toHaveBeenCalled(); + expect(socket.send).toHaveBeenCalledWith(expect.stringContaining('unauthorized_user_token')); + await client.end(); + }); +}); diff --git a/tests/getUser-redirect.test.ts b/tests/getUser-redirect.test.ts index 505b2cb..699ea4e 100644 --- a/tests/getUser-redirect.test.ts +++ b/tests/getUser-redirect.test.ts @@ -1,49 +1,134 @@ -import { describe, it, expect } from 'vitest'; - -describe('getUser redirect protection', () => { - it('should not loop infinitely on repeated redirects', async () => { - // Monkey-patch axios in the require cache to simulate redirect loop - let callCount = 0; - const axiosMock = { - get: async (url: string) => { - callCount += 1; - const isA = url === 'https://www.tradingview.com/'; - return { - data: 'no auth here', - headers: { - location: isA - ? 'https://www.tradingview.com/accounts/signin/' - : 'https://www.tradingview.com/', - }, - }; - }, - }; - - const axiosPath = require.resolve('axios'); - const originalModule = require.cache[axiosPath]; - require.cache[axiosPath] = { - id: axiosPath, - filename: axiosPath, - loaded: true, - exports: axiosMock, - } as any; - - const miscPath = require.resolve('../src/miscRequests'); - delete require.cache[miscPath]; - - try { - // eslint-disable-next-line global-require - const misc = require('../src/miscRequests'); - await expect( - misc.getUser('fake_session', 'fake_signature'), - ).rejects.toThrow('Too many redirects'); - - expect(callCount).toBeGreaterThan(0); - expect(callCount).toBeLessThanOrEqual(10); - } finally { - if (originalModule) require.cache[axiosPath] = originalModule; - else delete require.cache[axiosPath]; - delete require.cache[miscPath]; - } - }, 5000); +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; + +// Spy on the same axios instance required by the CommonJS library. +const axios = require('axios'); +const misc = require('../src/miscRequests'); + +const chartURL = 'https://www.tradingview.com/chart/'; +const authenticatedResponse = { + status: 200, + data: JSON.stringify({ id: 123, username: 'test_user', auth_token: 'test_auth_token' }), + headers: {}, +}; + +describe('getUser authentication and redirects', () => { + let get; + + beforeEach(() => { + get = vi.spyOn(axios, 'get'); + get.mockRejectedValue(new Error('Unexpected HTTP request')); + }); + afterEach(() => vi.restoreAllMocks()); + + it('authenticates against the chart page by default', async () => { + get.mockImplementation(async (url) => (url === chartURL + ? authenticatedResponse + : { status: 200, data: 'homepage without token', headers: {} })); + + await expect(misc.getUser('fake_session', 'fake_signature')).resolves.toMatchObject({ + id: '123', + username: 'test_user', + authToken: 'test_auth_token', + session: 'fake_session', + signature: 'fake_signature', + }); + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledWith(chartURL, expect.objectContaining({ + headers: { cookie: 'sessionid=fake_session;sessionid_sign=fake_signature' }, + maxRedirects: 0, + })); + }); + + it('preserves an explicit location and optional signature', async () => { + get.mockResolvedValue(authenticatedResponse); + const location = 'https://fr.tradingview.com/chart/'; + await misc.getUser('fake_session', undefined, location); + expect(get).toHaveBeenCalledWith(location, expect.objectContaining({ + headers: { cookie: 'sessionid=fake_session' }, + })); + }); + + it.each([200, 401, 403])('does not retry HTTP %i without Location', async (status) => { + get.mockResolvedValue({ status, data: 'no token', headers: {} }); + await expect(misc.getUser('fake_session', 'fake_signature')) + .rejects.toThrow('Wrong or expired sessionid/signature'); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('ignores Location on a successful non-redirect response', async () => { + get.mockResolvedValue({ + status: 200, data: 'no token', headers: { location: '/chart/' }, + }); + await expect(misc.getUser('fake_session', 'fake_signature')) + .rejects.toThrow('Wrong or expired sessionid/signature'); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('does not retry a redirect without Location', async () => { + get.mockResolvedValue({ status: 302, data: '', headers: {} }); + await expect(misc.getUser('fake_session', 'fake_signature')) + .rejects.toThrow('Wrong or expired sessionid/signature'); + expect(get).toHaveBeenCalledTimes(1); + }); + + it.each([301, 302, 303, 307, 308])('follows an HTTP %i redirect', async (status) => { + const location = 'https://fr.tradingview.com/chart/'; + get.mockResolvedValueOnce({ status, data: '', headers: { location } }) + .mockResolvedValueOnce(authenticatedResponse); + await expect(misc.getUser('fake_session', 'fake_signature')) + .resolves.toHaveProperty('authToken', 'test_auth_token'); + expect(get).toHaveBeenCalledTimes(2); + expect(get).toHaveBeenLastCalledWith(location, expect.objectContaining({ + headers: { cookie: 'sessionid=fake_session;sessionid_sign=fake_signature' }, + })); + }); + + it.each([ + ['/chart/redirected/', 'https://www.tradingview.com/chart/redirected/'], + ['next/', 'https://www.tradingview.com/chart/next/'], + ['//fr.tradingview.com/chart/', 'https://fr.tradingview.com/chart/'], + ])('resolves %s against the current URL', async (location, expectedURL) => { + get.mockResolvedValueOnce({ status: 302, data: '', headers: { location } }) + .mockResolvedValueOnce(authenticatedResponse); + await misc.getUser('fake_session', 'fake_signature'); + expect(get).toHaveBeenCalledTimes(2); + expect(get).toHaveBeenLastCalledWith(expectedURL, expect.any(Object)); + }); + + it.each([ + 'https://example.com/chart/', + 'https://tradingview.com.example.com/chart/', + 'https://nottradingview.com/chart/', + 'http://www.tradingview.com/chart/', + ])('rejects %s before forwarding cookies', async (location) => { + get.mockResolvedValue({ status: 302, data: '', headers: { location } }); + await expect(misc.getUser('fake_session', 'fake_signature')) + .rejects.toThrow('Unexpected authentication redirect destination'); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('allows authentication after five redirects', async () => { + get.mockImplementation(async () => (get.mock.calls.length <= 5 + ? { status: 302, data: '', headers: { location: chartURL } } + : authenticatedResponse)); + await expect(misc.getUser('fake_session', 'fake_signature')) + .resolves.toHaveProperty('authToken', 'test_auth_token'); + expect(get).toHaveBeenCalledTimes(6); + }); + + it('bounds a real redirect loop to six requests', async () => { + get.mockResolvedValue({ status: 302, data: '', headers: { location: chartURL } }); + await expect(misc.getUser('fake_session', 'fake_signature')) + .rejects.toThrow('Too many redirects'); + expect(get).toHaveBeenCalledTimes(6); + }); + + it('propagates network errors without retrying', async () => { + const error = new Error('Connection failed'); + get.mockRejectedValue(error); + await expect(misc.getUser('fake_session', 'fake_signature')).rejects.toBe(error); + expect(get).toHaveBeenCalledTimes(1); + }); });