@actual-app/sync-server 25.10.0-nightly.20250924 → 25.10.0-nightly.20250926

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ import express from 'express';
2
+ import rateLimit from 'express-rate-limit';
3
+ import ipaddr from 'ipaddr.js';
4
+ import { config } from './load-config.js';
5
+ import { requestLoggerMiddleware } from './util/middlewares.js';
6
+ import { validateSession } from './util/validate-user.js';
7
+ const app = express();
8
+ app.use(express.json());
9
+ app.use(requestLoggerMiddleware);
10
+ app.use(rateLimit({
11
+ windowMs: 60 * 1000,
12
+ max: 25,
13
+ legacyHeaders: false,
14
+ standardHeaders: true,
15
+ }));
16
+ // Cache for the allowlist to avoid fetching it on every request
17
+ let allowlistedRepos = [];
18
+ let lastAllowlistFetch = 0;
19
+ const ALLOWLIST_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
20
+ // Export cache clearing function for testing
21
+ export const clearAllowlistCache = () => {
22
+ allowlistedRepos = [];
23
+ lastAllowlistFetch = 0;
24
+ };
25
+ async function fetchAllowlist() {
26
+ const now = Date.now();
27
+ if (now - lastAllowlistFetch < ALLOWLIST_CACHE_TTL &&
28
+ allowlistedRepos.length > 0) {
29
+ return allowlistedRepos;
30
+ }
31
+ try {
32
+ const response = await fetch('https://raw.githubusercontent.com/actualbudget/plugin-store/refs/heads/main/plugins.json');
33
+ if (!response.ok) {
34
+ throw new Error(`Failed to fetch allowlist: ${response.status}`);
35
+ }
36
+ const plugins = await response.json();
37
+ allowlistedRepos = plugins.map(plugin => plugin.url);
38
+ lastAllowlistFetch = now;
39
+ console.log('Updated plugin allowlist:', allowlistedRepos);
40
+ return allowlistedRepos;
41
+ }
42
+ catch (error) {
43
+ console.error('Failed to fetch plugin allowlist:', error);
44
+ // Return empty array if fetch fails to be safe
45
+ allowlistedRepos = [];
46
+ return allowlistedRepos;
47
+ }
48
+ }
49
+ /**
50
+ * Return true only if the URL is on an allowlist and not a local/private address.
51
+ */
52
+ function isUrlAllowed(targetUrl) {
53
+ try {
54
+ const url = new URL(targetUrl);
55
+ const hostname = url.hostname;
56
+ // Block private/local IP addresses
57
+ if (ipaddr.isValid(hostname)) {
58
+ const ip = ipaddr.parse(hostname);
59
+ if ([
60
+ 'private',
61
+ 'loopback',
62
+ 'linkLocal',
63
+ 'uniqueLocal',
64
+ 'unspecified',
65
+ ].includes(ip.range())) {
66
+ console.warn(`Blocked request to private/localhost IP: ${hostname}`);
67
+ return false;
68
+ }
69
+ }
70
+ // Always allow the specific plugin-store URL
71
+ if (targetUrl ===
72
+ 'https://raw.githubusercontent.com/actualbudget/plugin-store/refs/heads/main/plugins.json') {
73
+ return true;
74
+ }
75
+ // Check against allowlisted repositories
76
+ for (const repoUrl of allowlistedRepos) {
77
+ try {
78
+ const { pathname } = new URL(repoUrl);
79
+ const [, repoOwner, repoName] = pathname.split('/');
80
+ if (targetUrl === repoUrl ||
81
+ targetUrl.startsWith(repoUrl + '/') ||
82
+ (hostname === 'api.github.com' &&
83
+ url.pathname.startsWith(`/repos/${repoOwner}/${repoName}`)) ||
84
+ (hostname === 'raw.githubusercontent.com' &&
85
+ url.pathname.startsWith(`/${repoOwner}/${repoName}/`)) ||
86
+ (hostname === 'github.com' &&
87
+ url.pathname.startsWith(`/${repoOwner}/${repoName}/releases/`))) {
88
+ return true;
89
+ }
90
+ }
91
+ catch (e) {
92
+ console.warn('Invalid repository URL in allowlist:', repoUrl, e.message);
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+ catch (e) {
98
+ console.warn('Invalid target URL:', targetUrl, e.message);
99
+ return false;
100
+ }
101
+ }
102
+ app.use('/', async (req, res) => {
103
+ // CORS preflight
104
+ if (req.method === 'OPTIONS') {
105
+ res.set('Access-Control-Allow-Origin', '*');
106
+ res.set('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS');
107
+ res.set('Access-Control-Allow-Headers', 'Content-Type, X-Actual-Token');
108
+ res.set('Access-Control-Max-Age', '600');
109
+ return res.status(204).end();
110
+ }
111
+ const targetUrlString = req.query.url;
112
+ if (!targetUrlString) {
113
+ return res.status(400).json({ error: 'Missing url parameter' });
114
+ }
115
+ // Validate session/token
116
+ const session = await validateSession(req, res);
117
+ if (!session) {
118
+ return; // validateSession already sent the response
119
+ }
120
+ let url;
121
+ try {
122
+ url = new URL(targetUrlString);
123
+ }
124
+ catch (e) {
125
+ return res.status(400).json({ error: 'Invalid url parameter' });
126
+ }
127
+ // Fetch the latest allowlist
128
+ try {
129
+ await fetchAllowlist();
130
+ }
131
+ catch (error) {
132
+ console.error('Failed to fetch allowlist:', error);
133
+ return res.status(403).json({
134
+ error: 'URL not allowed',
135
+ message: 'Unable to verify allowlist',
136
+ });
137
+ }
138
+ // Check if the URL is allowed
139
+ if (!isUrlAllowed(url.href)) {
140
+ console.warn('Blocked request to unauthorized URL:', url.href);
141
+ return res.status(403).json({
142
+ error: 'URL not allowed',
143
+ message: 'Only allowlisted plugin repositories are allowed (localhost only in development)',
144
+ });
145
+ }
146
+ try {
147
+ // Extract method, body, and headers from the request body (sent by loot-core)
148
+ const { method = 'GET', body, headers: customHeaders = {}, } = req.body || {};
149
+ const methodNormalized = typeof method === 'string' ? method.toUpperCase() : 'GET';
150
+ if (!['GET', 'HEAD'].includes(methodNormalized)) {
151
+ return res.status(405).json({ error: 'Method not allowed' });
152
+ }
153
+ const requestHeaders = {
154
+ ...req.headers,
155
+ ...customHeaders,
156
+ host: url.host,
157
+ };
158
+ // Remove headers that shouldn't be forwarded
159
+ delete requestHeaders['x-actual-token'];
160
+ delete requestHeaders['content-length'];
161
+ delete requestHeaders['cookie'];
162
+ delete requestHeaders['cookie2'];
163
+ // Add GitHub authentication if token is configured and request is to GitHub
164
+ const githubToken = config.get('github.token');
165
+ if (githubToken &&
166
+ (url.hostname === 'api.github.com' ||
167
+ url.hostname === 'raw.githubusercontent.com' ||
168
+ (url.hostname === 'github.com' && url.pathname.includes('/releases/')))) {
169
+ requestHeaders['Authorization'] = `Bearer ${githubToken}`;
170
+ requestHeaders['User-Agent'] = 'Actual-Budget-Plugin-System';
171
+ console.log(`Using GitHub authentication for request to: ${url.hostname}`);
172
+ }
173
+ const response = await fetch(url.href, {
174
+ method,
175
+ headers: requestHeaders,
176
+ body: ['GET', 'HEAD'].includes(method)
177
+ ? undefined
178
+ : typeof body === 'string'
179
+ ? body
180
+ : JSON.stringify(body),
181
+ });
182
+ const contentType = response.headers.get('content-type') || 'application/octet-stream';
183
+ res.set('Access-Control-Allow-Origin', '*');
184
+ res.status(response.status);
185
+ // Try to detect if this might be JSON content based on URL or content
186
+ const urlString = url.toString().toLowerCase();
187
+ const isLikelyJson = contentType?.includes('application/json') ||
188
+ urlString.includes('.json') ||
189
+ urlString.includes('/manifest') ||
190
+ urlString.includes('manifest.json') ||
191
+ urlString.includes('package.json');
192
+ if (isLikelyJson) {
193
+ // For JSON responses, return the actual content
194
+ res.set('Content-Type', 'application/json');
195
+ const text = await response.text();
196
+ try {
197
+ res.json(JSON.parse(text));
198
+ }
199
+ catch {
200
+ // If it's not valid JSON, treat as text
201
+ res.set('Content-Type', contentType || 'text/plain');
202
+ res.send(text);
203
+ }
204
+ }
205
+ else if (contentType?.includes('text/')) {
206
+ // For text responses, return as plain text
207
+ res.set('Content-Type', contentType);
208
+ const text = await response.text();
209
+ res.send(text);
210
+ }
211
+ else {
212
+ // For actual binary responses, return as JSON format
213
+ res.set('Content-Type', 'application/json');
214
+ const buffer = await response.arrayBuffer();
215
+ const binaryData = {
216
+ data: Array.from(new Uint8Array(buffer)),
217
+ contentType,
218
+ isBinary: true,
219
+ };
220
+ res.json(binaryData);
221
+ }
222
+ }
223
+ catch (err) {
224
+ res
225
+ .status(500)
226
+ .json({ error: 'Error proxying request', details: err.message });
227
+ }
228
+ });
229
+ export { app as handlers };
@@ -0,0 +1,448 @@
1
+ import ipaddr from 'ipaddr.js';
2
+ import request from 'supertest';
3
+ import { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest';
4
+ import { handlers as app, clearAllowlistCache } from './app-cors-proxy.js';
5
+ import { config } from './load-config.js';
6
+ import { validateSession } from './util/validate-user.js';
7
+ vi.mock('./load-config.js', () => ({
8
+ config: {
9
+ get: vi.fn(),
10
+ },
11
+ }));
12
+ vi.mock('./util/middlewares.js', () => ({
13
+ requestLoggerMiddleware: (req, res, next) => next(),
14
+ }));
15
+ vi.mock('./util/validate-user.js', () => ({
16
+ validateSession: vi.fn(),
17
+ }));
18
+ vi.mock('express-rate-limit', () => ({
19
+ default: vi.fn(() => (req, res, next) => next()),
20
+ }));
21
+ vi.mock('ipaddr.js', () => ({
22
+ default: {
23
+ isValid: vi.fn().mockReturnValue(false),
24
+ parse: vi.fn(),
25
+ },
26
+ }));
27
+ global.fetch = vi.fn();
28
+ describe('app-cors-proxy', () => {
29
+ const defaultAllowlistedRepos = [
30
+ 'https://github.com/user/repo1',
31
+ 'https://github.com/user/repo2',
32
+ ];
33
+ const createFetchMock = (options = {}) => {
34
+ const { allowlistedRepos = defaultAllowlistedRepos, allowlistFetchFails = false, allowlistHttpError = false, proxyResponses = {}, } = options;
35
+ return vi.fn().mockImplementation((url, _requestOptions) => {
36
+ if (url ===
37
+ 'https://raw.githubusercontent.com/actualbudget/plugin-store/refs/heads/main/plugins.json') {
38
+ if (allowlistFetchFails) {
39
+ return Promise.reject(new Error('Network error'));
40
+ }
41
+ if (allowlistHttpError) {
42
+ return Promise.resolve({
43
+ ok: false,
44
+ status: 404,
45
+ });
46
+ }
47
+ return Promise.resolve({
48
+ ok: true,
49
+ json: () => Promise.resolve(allowlistedRepos.map(repoUrl => ({ url: repoUrl }))),
50
+ });
51
+ }
52
+ if (proxyResponses[url]) {
53
+ const response = proxyResponses[url];
54
+ if (response.error) {
55
+ return Promise.reject(response.error);
56
+ }
57
+ return Promise.resolve(response);
58
+ }
59
+ return Promise.resolve({
60
+ ok: true,
61
+ text: () => Promise.resolve('default response'),
62
+ headers: {
63
+ get: () => 'text/plain',
64
+ },
65
+ status: 200,
66
+ });
67
+ });
68
+ };
69
+ const comprehensiveProxyResponses = {
70
+ 'https://github.com/user/repo1': {
71
+ ok: true,
72
+ text: () => Promise.resolve('test content'),
73
+ headers: { get: () => 'text/plain' },
74
+ status: 200,
75
+ },
76
+ 'https://api.github.com/repos/user/repo1/releases': {
77
+ ok: true,
78
+ text: () => Promise.resolve('{"name": "test"}'),
79
+ headers: { get: () => 'application/json' },
80
+ status: 200,
81
+ },
82
+ 'https://api.github.com/repos/user/repo1': {
83
+ ok: true,
84
+ text: () => Promise.resolve('{"name": "repo1"}'),
85
+ headers: { get: () => 'application/json' },
86
+ status: 200,
87
+ },
88
+ 'https://raw.githubusercontent.com/user/repo1/main/file.txt': {
89
+ ok: true,
90
+ text: () => Promise.resolve('file content'),
91
+ headers: { get: () => 'text/plain' },
92
+ status: 200,
93
+ },
94
+ 'https://github.com/user/repo1/releases/download/v1.0.0/file.zip': {
95
+ ok: true,
96
+ arrayBuffer: () => Promise.resolve(new TextEncoder().encode('release content').buffer),
97
+ headers: { get: () => 'application/octet-stream' },
98
+ status: 200,
99
+ },
100
+ 'https://github.com/user/repo1/manifest.json': {
101
+ ok: true,
102
+ text: () => Promise.resolve(JSON.stringify({ name: 'test', version: '1.0.0' })),
103
+ headers: { get: () => 'application/json' },
104
+ status: 200,
105
+ },
106
+ 'https://github.com/user/repo1/package.json': {
107
+ ok: true,
108
+ text: () => Promise.resolve(JSON.stringify({ test: true })),
109
+ headers: { get: () => 'text/plain' },
110
+ status: 200,
111
+ },
112
+ 'https://github.com/user/repo1/readme.txt': {
113
+ ok: true,
114
+ text: () => Promise.resolve('Hello, world!'),
115
+ headers: { get: () => 'text/plain' },
116
+ status: 200,
117
+ },
118
+ 'https://github.com/user/repo1/file.bin': {
119
+ ok: true,
120
+ arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3, 4, 5]).buffer),
121
+ headers: { get: () => 'application/octet-stream' },
122
+ status: 200,
123
+ },
124
+ 'https://github.com/user/repo1/invalid.json': {
125
+ ok: true,
126
+ text: () => Promise.resolve('not valid json'),
127
+ headers: { get: () => 'text/plain' },
128
+ status: 200,
129
+ },
130
+ 'https://github.com/user/repo1/network-error': {
131
+ error: new Error('Network error'),
132
+ },
133
+ };
134
+ beforeAll(() => {
135
+ global.fetch = createFetchMock({
136
+ proxyResponses: comprehensiveProxyResponses,
137
+ });
138
+ });
139
+ beforeEach(() => {
140
+ validateSession.mockClear?.();
141
+ ipaddr.isValid.mockClear?.();
142
+ ipaddr.parse.mockClear?.();
143
+ validateSession.mockReturnValue({ userId: 'test-user' });
144
+ clearAllowlistCache();
145
+ vi.spyOn(console, 'log').mockImplementation(() => { });
146
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
147
+ vi.spyOn(console, 'error').mockImplementation(() => { });
148
+ });
149
+ describe('CORS preflight', () => {
150
+ it('should handle OPTIONS requests properly', async () => {
151
+ const res = await request(app)
152
+ .options('/')
153
+ .query({ url: 'https://example.com' });
154
+ expect(res.statusCode).toBe(204);
155
+ expect(res.headers['access-control-allow-origin']).toBe('*');
156
+ expect(res.headers['access-control-allow-methods']).toBe('GET,HEAD,OPTIONS');
157
+ expect(res.headers['access-control-allow-headers']).toBe('Content-Type, X-Actual-Token');
158
+ expect(res.headers['access-control-max-age']).toBe('600');
159
+ });
160
+ });
161
+ describe('URL parameter validation', () => {
162
+ it('should return 400 if url parameter is missing', async () => {
163
+ validateSession.mockReturnValue({ userId: 'test-user' });
164
+ const res = await request(app).get('/');
165
+ expect(res.statusCode).toBe(400);
166
+ expect(res.body.error).toBe('Missing url parameter');
167
+ });
168
+ it('should return 400 if url parameter is invalid', async () => {
169
+ validateSession.mockReturnValue({ userId: 'test-user' });
170
+ const res = await request(app).get('/').query({ url: 'invalid-url' });
171
+ expect(res.statusCode).toBe(400);
172
+ expect(res.body.error).toBe('Invalid url parameter');
173
+ });
174
+ });
175
+ describe('Session validation', () => {
176
+ it('should return early if validateSession fails', async () => {
177
+ validateSession.mockImplementation((req, res) => {
178
+ res.status(401).json({ error: 'Unauthorized' });
179
+ return null;
180
+ });
181
+ const res = await request(app)
182
+ .get('/')
183
+ .query({ url: 'https://example.com' });
184
+ expect(res.statusCode).toBe(401);
185
+ expect(validateSession).toHaveBeenCalledTimes(1);
186
+ });
187
+ });
188
+ describe('URL allowlist validation', () => {
189
+ beforeEach(() => {
190
+ validateSession.mockReturnValue({ userId: 'test-user' });
191
+ });
192
+ it('should block private IP addresses', async () => {
193
+ ipaddr.isValid.mockReturnValueOnce(true);
194
+ ipaddr.parse.mockReturnValueOnce({
195
+ range: () => 'private',
196
+ });
197
+ const res = await request(app)
198
+ .get('/')
199
+ .query({ url: 'http://192.168.1.1/test' });
200
+ expect(res.statusCode).toBe(403);
201
+ expect(res.body.error).toBe('URL not allowed');
202
+ expect(console.warn).toHaveBeenCalledWith('Blocked request to private/localhost IP: 192.168.1.1');
203
+ });
204
+ it('should block loopback addresses', async () => {
205
+ ipaddr.isValid.mockReturnValueOnce(true);
206
+ ipaddr.parse.mockReturnValueOnce({
207
+ range: () => 'loopback',
208
+ });
209
+ const res = await request(app)
210
+ .get('/')
211
+ .query({ url: 'http://127.0.0.1/test' });
212
+ expect(res.statusCode).toBe(403);
213
+ expect(res.body.error).toBe('URL not allowed');
214
+ });
215
+ it('should allow allowlisted repository URLs', async () => {
216
+ const res = await request(app)
217
+ .get('/')
218
+ .query({ url: 'https://github.com/user/repo1' });
219
+ expect(res.statusCode).toBe(200);
220
+ });
221
+ it('should allow GitHub API URLs for allowlisted repos', async () => {
222
+ const res = await request(app)
223
+ .get('/')
224
+ .query({ url: 'https://api.github.com/repos/user/repo1/releases' });
225
+ expect(res.statusCode).toBe(200);
226
+ });
227
+ it('should allow raw.githubusercontent.com URLs for allowlisted repos', async () => {
228
+ const res = await request(app).get('/').query({
229
+ url: 'https://raw.githubusercontent.com/user/repo1/main/file.txt',
230
+ });
231
+ expect(res.statusCode).toBe(200);
232
+ });
233
+ it('should allow github.com release URLs for allowlisted repos', async () => {
234
+ const res = await request(app).get('/').query({
235
+ url: 'https://github.com/user/repo1/releases/download/v1.0.0/file.zip',
236
+ });
237
+ expect(res.statusCode).toBe(200);
238
+ });
239
+ it('should block non-allowlisted URLs', async () => {
240
+ const res = await request(app)
241
+ .get('/')
242
+ .query({ url: 'https://malicious.com/evil' });
243
+ expect(res.statusCode).toBe(403);
244
+ expect(res.body.error).toBe('URL not allowed');
245
+ expect(console.warn).toHaveBeenCalledWith('Blocked request to unauthorized URL:', 'https://malicious.com/evil');
246
+ });
247
+ });
248
+ describe('Allowlist fetching and caching', () => {
249
+ beforeEach(() => {
250
+ validateSession.mockReturnValue({ userId: 'test-user' });
251
+ });
252
+ it('should fetch allowlist on first request', async () => {
253
+ await request(app)
254
+ .get('/')
255
+ .query({ url: 'https://github.com/user/repo1' });
256
+ expect(global.fetch).toHaveBeenCalledWith('https://raw.githubusercontent.com/actualbudget/plugin-store/refs/heads/main/plugins.json');
257
+ });
258
+ it('should handle allowlist fetch failure gracefully', async () => {
259
+ global.fetch = createFetchMock({
260
+ allowlistFetchFails: true,
261
+ proxyResponses: comprehensiveProxyResponses,
262
+ });
263
+ const res = await request(app)
264
+ .get('/')
265
+ .query({ url: 'https://github.com/user/repo1' });
266
+ expect(res.statusCode).toBe(403);
267
+ expect(console.error).toHaveBeenCalledWith('Failed to fetch plugin allowlist:', expect.any(Error));
268
+ global.fetch = createFetchMock({
269
+ proxyResponses: comprehensiveProxyResponses,
270
+ });
271
+ });
272
+ it('should handle allowlist fetch HTTP error', async () => {
273
+ global.fetch = createFetchMock({
274
+ allowlistHttpError: true,
275
+ proxyResponses: comprehensiveProxyResponses,
276
+ });
277
+ const res = await request(app)
278
+ .get('/')
279
+ .query({ url: 'https://github.com/user/repo1' });
280
+ expect(res.statusCode).toBe(403);
281
+ expect(console.error).toHaveBeenCalledWith('Failed to fetch plugin allowlist:', expect.any(Error));
282
+ global.fetch = createFetchMock({
283
+ proxyResponses: comprehensiveProxyResponses,
284
+ });
285
+ });
286
+ });
287
+ describe('HTTP method validation', () => {
288
+ beforeEach(() => {
289
+ validateSession.mockReturnValue({ userId: 'test-user' });
290
+ });
291
+ it('should allow GET method', async () => {
292
+ const res = await request(app)
293
+ .get('/')
294
+ .send({ method: 'GET' })
295
+ .query({ url: 'https://github.com/user/repo1' });
296
+ expect(res.statusCode).toBe(200);
297
+ });
298
+ it('should allow HEAD method', async () => {
299
+ const res = await request(app)
300
+ .get('/')
301
+ .send({ method: 'HEAD' })
302
+ .query({ url: 'https://github.com/user/repo1' });
303
+ expect(res.statusCode).toBe(200);
304
+ });
305
+ it('should block POST method', async () => {
306
+ const res = await request(app)
307
+ .get('/')
308
+ .send({ method: 'POST' })
309
+ .query({ url: 'https://github.com/user/repo1' });
310
+ expect(res.statusCode).toBe(405);
311
+ expect(res.body.error).toBe('Method not allowed');
312
+ });
313
+ it('should block PUT method', async () => {
314
+ const res = await request(app)
315
+ .get('/')
316
+ .send({ method: 'PUT' })
317
+ .query({ url: 'https://github.com/user/repo1' });
318
+ expect(res.statusCode).toBe(405);
319
+ expect(res.body.error).toBe('Method not allowed');
320
+ });
321
+ });
322
+ describe('GitHub authentication', () => {
323
+ beforeEach(() => {
324
+ validateSession.mockReturnValue({ userId: 'test-user' });
325
+ });
326
+ it('should add GitHub authentication for api.github.com when token is configured', async () => {
327
+ config.get.mockReturnValue('test-github-token');
328
+ await request(app)
329
+ .get('/')
330
+ .query({ url: 'https://api.github.com/repos/user/repo1' });
331
+ expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/repos/user/repo1', expect.objectContaining({
332
+ headers: expect.objectContaining({
333
+ Authorization: 'Bearer test-github-token',
334
+ 'User-Agent': 'Actual-Budget-Plugin-System',
335
+ }),
336
+ }));
337
+ });
338
+ it('should add GitHub authentication for raw.githubusercontent.com when token is configured', async () => {
339
+ config.get.mockReturnValue('test-github-token');
340
+ await request(app).get('/').query({
341
+ url: 'https://raw.githubusercontent.com/user/repo1/main/file.txt',
342
+ });
343
+ expect(global.fetch).toHaveBeenCalledWith('https://raw.githubusercontent.com/user/repo1/main/file.txt', expect.objectContaining({
344
+ headers: expect.objectContaining({
345
+ Authorization: 'Bearer test-github-token',
346
+ 'User-Agent': 'Actual-Budget-Plugin-System',
347
+ }),
348
+ }));
349
+ });
350
+ it('should not add GitHub authentication when token is not configured', async () => {
351
+ config.get.mockReturnValue(null);
352
+ await request(app)
353
+ .get('/')
354
+ .query({ url: 'https://api.github.com/repos/user/repo1' });
355
+ expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/repos/user/repo1', expect.objectContaining({
356
+ headers: expect.not.objectContaining({
357
+ Authorization: expect.any(String),
358
+ }),
359
+ }));
360
+ });
361
+ });
362
+ describe('Response handling', () => {
363
+ beforeEach(() => {
364
+ validateSession.mockReturnValue({ userId: 'test-user' });
365
+ });
366
+ it('should handle JSON responses', async () => {
367
+ const jsonData = { name: 'test', version: '1.0.0' };
368
+ const res = await request(app)
369
+ .get('/')
370
+ .query({ url: 'https://github.com/user/repo1/manifest.json' });
371
+ expect(res.statusCode).toBe(200);
372
+ expect(res.headers['content-type']).toContain('application/json');
373
+ expect(res.body).toEqual(jsonData);
374
+ });
375
+ it('should handle text responses', async () => {
376
+ const textContent = 'Hello, world!';
377
+ const res = await request(app)
378
+ .get('/')
379
+ .query({ url: 'https://github.com/user/repo1/readme.txt' });
380
+ expect(res.statusCode).toBe(200);
381
+ expect(res.headers['content-type']).toContain('text/plain');
382
+ expect(res.text).toBe(textContent);
383
+ });
384
+ it('should handle binary responses', async () => {
385
+ const res = await request(app)
386
+ .get('/')
387
+ .query({ url: 'https://github.com/user/repo1/file.bin' });
388
+ expect(res.statusCode).toBe(200);
389
+ expect(res.headers['content-type']).toContain('application/json');
390
+ expect(res.body).toEqual({
391
+ data: [1, 2, 3, 4, 5],
392
+ contentType: 'application/octet-stream',
393
+ isBinary: true,
394
+ });
395
+ });
396
+ it('should handle invalid JSON gracefully', async () => {
397
+ const res = await request(app)
398
+ .get('/')
399
+ .query({ url: 'https://github.com/user/repo1/invalid.json' });
400
+ expect(res.statusCode).toBe(200);
401
+ expect(res.text).toBe('not valid json');
402
+ });
403
+ it('should detect JSON from URL patterns', async () => {
404
+ const jsonData = { test: true };
405
+ const res = await request(app)
406
+ .get('/')
407
+ .query({ url: 'https://github.com/user/repo1/package.json' });
408
+ expect(res.statusCode).toBe(200);
409
+ expect(res.headers['content-type']).toContain('application/json');
410
+ expect(res.body).toEqual(jsonData);
411
+ });
412
+ });
413
+ describe('Error handling', () => {
414
+ beforeEach(() => {
415
+ validateSession.mockReturnValue({ userId: 'test-user' });
416
+ });
417
+ it('should handle fetch errors', async () => {
418
+ const res = await request(app)
419
+ .get('/')
420
+ .query({ url: 'https://github.com/user/repo1/network-error' });
421
+ expect(res.statusCode).toBe(500);
422
+ expect(res.body.error).toBe('Error proxying request');
423
+ expect(res.body.details).toBe('Network error');
424
+ });
425
+ it('should handle invalid repository URLs in allowlist', async () => {
426
+ global.fetch.mockResolvedValueOnce({
427
+ ok: true,
428
+ json: () => Promise.resolve([{ url: 'invalid-url' }]),
429
+ });
430
+ const res = await request(app)
431
+ .get('/')
432
+ .query({ url: 'https://example.com' });
433
+ expect(res.statusCode).toBe(403);
434
+ expect(console.warn).toHaveBeenCalledWith('Blocked request to unauthorized URL:', 'https://example.com/');
435
+ });
436
+ });
437
+ describe('CORS headers', () => {
438
+ beforeEach(() => {
439
+ validateSession.mockReturnValue({ userId: 'test-user' });
440
+ });
441
+ it('should set CORS headers on successful responses', async () => {
442
+ const res = await request(app)
443
+ .get('/')
444
+ .query({ url: 'https://github.com/user/repo1' });
445
+ expect(res.headers['access-control-allow-origin']).toBe('*');
446
+ });
447
+ });
448
+ });
package/build/src/app.js CHANGED
@@ -7,6 +7,7 @@ import rateLimit from 'express-rate-limit';
7
7
  import { bootstrap } from './account-db.js';
8
8
  import * as accountApp from './app-account.js';
9
9
  import * as adminApp from './app-admin.js';
10
+ import * as corsApp from './app-cors-proxy.js';
10
11
  import * as goCardlessApp from './app-gocardless/app-gocardless.js';
11
12
  import * as openidApp from './app-openid.js';
12
13
  import * as pluggai from './app-pluggyai/app-pluggyai.js';
@@ -44,6 +45,9 @@ app.use('/gocardless', goCardlessApp.handlers);
44
45
  app.use('/simplefin', simpleFinApp.handlers);
45
46
  app.use('/pluggyai', pluggai.handlers);
46
47
  app.use('/secret', secretApp.handlers);
48
+ if (config.get('corsProxy.enabled')) {
49
+ app.use('/cors-proxy', corsApp.handlers);
50
+ }
47
51
  app.use('/admin', adminApp.handlers);
48
52
  app.use('/openid', openidApp.handlers);
49
53
  app.get('/mode', (req, res) => {
@@ -26,7 +26,26 @@ convict.addFormat({
26
26
  return;
27
27
  if (typeof val === 'number' && Number.isFinite(val) && val >= 0)
28
28
  return;
29
- throw new Error(`Invalid token_expiration value: ${val}`);
29
+ // Handle string values that can be converted to numbers (from env vars)
30
+ if (typeof val === 'string') {
31
+ const numVal = Number(val);
32
+ if (Number.isFinite(numVal) && numVal >= 0)
33
+ return;
34
+ }
35
+ throw new Error(`Invalid token_expiration value: ${val}: value was "${val}"`);
36
+ },
37
+ coerce(val) {
38
+ if (val === 'never' || val === 'openid-provider')
39
+ return val;
40
+ if (typeof val === 'number')
41
+ return val;
42
+ // Convert string values to numbers for environment variables
43
+ if (typeof val === 'string') {
44
+ const numVal = Number(val);
45
+ if (Number.isFinite(numVal) && numVal >= 0)
46
+ return numVal;
47
+ }
48
+ return val; // Let validate() handle invalid values
30
49
  },
31
50
  });
32
51
  // Main config schema
@@ -233,6 +252,24 @@ const configSchema = convict({
233
252
  default: 'manual',
234
253
  env: 'ACTUAL_USER_CREATION_MODE',
235
254
  },
255
+ github: {
256
+ doc: 'GitHub API configuration.',
257
+ token: {
258
+ doc: 'GitHub Personal Access Token for API authentication.',
259
+ format: String,
260
+ default: '',
261
+ env: 'ACTUAL_GITHUB_TOKEN',
262
+ },
263
+ },
264
+ corsProxy: {
265
+ doc: 'CORS proxy configuration for frontend plugins.',
266
+ enabled: {
267
+ doc: 'Enable the CORS proxy endpoint.',
268
+ format: Boolean,
269
+ default: false,
270
+ env: 'ACTUAL_CORS_PROXY_ENABLED',
271
+ },
272
+ },
236
273
  });
237
274
  let configPath = null;
238
275
  if (process.env.ACTUAL_CONFIG_PATH) {
@@ -261,6 +298,8 @@ debug(`User files: ${configSchema.get('userFiles')}`);
261
298
  debug(`Web root: ${configSchema.get('webRoot')}`);
262
299
  debug(`Login method: ${configSchema.get('loginMethod')}`);
263
300
  debug(`Allowed methods: ${configSchema.get('allowedLoginMethods').join(', ')}`);
301
+ const corsProxyEnabled = configSchema.get('corsProxy.enabled');
302
+ debug(`CORS Proxy enabled: ${corsProxyEnabled}`);
264
303
  const httpsKey = configSchema.get('https.key');
265
304
  if (httpsKey) {
266
305
  debug(`HTTPS Key: ${'*'.repeat(httpsKey.length)}`);
@@ -271,4 +310,9 @@ if (httpsCert) {
271
310
  debug(`HTTPS Cert: ${'*'.repeat(httpsCert.length)}`);
272
311
  debugSensitive(`HTTPS Cert: ${httpsCert}`);
273
312
  }
313
+ const githubToken = configSchema.get('github.token');
314
+ if (githubToken) {
315
+ debug(`GitHub Token: ${'*'.repeat(Math.min(githubToken.length, 20))}`);
316
+ debugSensitive(`GitHub Token: ${githubToken}`);
317
+ }
274
318
  export { configSchema as config };
@@ -0,0 +1,92 @@
1
+ import convict from 'convict';
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
+ // Import the custom format
4
+ import './load-config.js';
5
+ describe('tokenExpiration format', () => {
6
+ let originalEnv;
7
+ beforeEach(() => {
8
+ originalEnv = { ...process.env };
9
+ });
10
+ afterEach(() => {
11
+ process.env = originalEnv;
12
+ });
13
+ it('should accept string numbers from environment variables', () => {
14
+ // Test string number
15
+ process.env.TEST_TOKEN_EXPIRATION = '86400';
16
+ const testSchema = convict({
17
+ token_expiration: {
18
+ format: 'tokenExpiration',
19
+ default: 'never',
20
+ env: 'TEST_TOKEN_EXPIRATION',
21
+ },
22
+ });
23
+ expect(() => testSchema.validate()).not.toThrow();
24
+ expect(testSchema.get('token_expiration')).toBe(86400);
25
+ expect(typeof testSchema.get('token_expiration')).toBe('number');
26
+ });
27
+ it('should accept different string numbers', () => {
28
+ const testSchema = convict({
29
+ token_expiration: {
30
+ format: 'tokenExpiration',
31
+ default: 'never',
32
+ env: 'TEST_TOKEN_EXPIRATION',
33
+ },
34
+ });
35
+ // Test different string numbers
36
+ const testCases = ['3600', '7200', '0'];
37
+ for (const testValue of testCases) {
38
+ process.env.TEST_TOKEN_EXPIRATION = testValue;
39
+ testSchema.load({});
40
+ expect(() => testSchema.validate()).not.toThrow();
41
+ expect(testSchema.get('token_expiration')).toBe(Number(testValue));
42
+ expect(typeof testSchema.get('token_expiration')).toBe('number');
43
+ }
44
+ });
45
+ it('should accept special string values', () => {
46
+ const testSchema = convict({
47
+ token_expiration: {
48
+ format: 'tokenExpiration',
49
+ default: 'never',
50
+ env: 'TEST_TOKEN_EXPIRATION',
51
+ },
52
+ });
53
+ // Test 'never' value
54
+ process.env.TEST_TOKEN_EXPIRATION = 'never';
55
+ testSchema.load({});
56
+ expect(() => testSchema.validate()).not.toThrow();
57
+ expect(testSchema.get('token_expiration')).toBe('never');
58
+ // Test 'openid-provider' value
59
+ process.env.TEST_TOKEN_EXPIRATION = 'openid-provider';
60
+ testSchema.load({});
61
+ expect(() => testSchema.validate()).not.toThrow();
62
+ expect(testSchema.get('token_expiration')).toBe('openid-provider');
63
+ });
64
+ it('should accept numeric values directly', () => {
65
+ const testSchema = convict({
66
+ token_expiration: {
67
+ format: 'tokenExpiration',
68
+ default: 'never',
69
+ },
70
+ });
71
+ testSchema.set('token_expiration', 86400);
72
+ expect(() => testSchema.validate()).not.toThrow();
73
+ expect(testSchema.get('token_expiration')).toBe(86400);
74
+ });
75
+ it('should reject invalid string values', () => {
76
+ const testSchema = convict({
77
+ token_expiration: {
78
+ format: 'tokenExpiration',
79
+ default: 'never',
80
+ env: 'TEST_TOKEN_EXPIRATION',
81
+ },
82
+ });
83
+ // Test invalid string
84
+ process.env.TEST_TOKEN_EXPIRATION = 'invalid';
85
+ testSchema.load({});
86
+ expect(() => testSchema.validate()).toThrow(/Invalid token_expiration value/);
87
+ // Test negative number as string
88
+ process.env.TEST_TOKEN_EXPIRATION = '-100';
89
+ testSchema.load({});
90
+ expect(() => testSchema.validate()).toThrow(/Invalid token_expiration value/);
91
+ });
92
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actual-app/sync-server",
3
- "version": "25.10.0-nightly.20250924",
3
+ "version": "25.10.0-nightly.20250926",
4
4
  "license": "MIT",
5
5
  "description": "actual syncing server",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@actual-app/crdt": "2.1.0",
31
- "@actual-app/web": "25.10.0-nightly.20250924",
31
+ "@actual-app/web": "25.10.0-nightly.20250926",
32
32
  "bcrypt": "^6.0.0",
33
33
  "better-sqlite3": "^12.2.0",
34
34
  "convict": "^6.2.4",
@@ -38,6 +38,7 @@
38
38
  "express": "5.1.0",
39
39
  "express-rate-limit": "^8.0.1",
40
40
  "express-winston": "^4.2.0",
41
+ "ipaddr.js": "^2.2.0",
41
42
  "jws": "^4.0.0",
42
43
  "migrate": "^2.1.0",
43
44
  "nordigen-node": "^1.4.1",