@actual-app/sync-server 25.10.0-nightly.20250923 → 25.10.0-nightly.20250925
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.
- package/build/src/app-cors-proxy.js +229 -0
- package/build/src/app-cors-proxy.test.js +448 -0
- package/build/src/app-sync.js +10 -3
- package/build/src/app.js +4 -0
- package/build/src/load-config.js +25 -0
- package/package.json +3 -2
|
@@ -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-sync.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getAccountDb } from './account-db.js';
|
|
|
8
8
|
import { FileNotFound } from './app-sync/errors.js';
|
|
9
9
|
import { File, FilesService, FileUpdate, } from './app-sync/services/files-service.js';
|
|
10
10
|
import { validateSyncedFile, validateUploadedFile, } from './app-sync/validation.js';
|
|
11
|
+
import { config } from './load-config.js';
|
|
11
12
|
import * as simpleSync from './sync-simple.js';
|
|
12
13
|
import { errorMiddleware, requestLoggerMiddleware, validateSessionMiddleware, } from './util/middlewares.js';
|
|
13
14
|
import { getPathForUserFile, getPathForGroupFile } from './util/paths.js';
|
|
@@ -15,9 +16,15 @@ const app = express();
|
|
|
15
16
|
app.use(validateSessionMiddleware);
|
|
16
17
|
app.use(errorMiddleware);
|
|
17
18
|
app.use(requestLoggerMiddleware);
|
|
18
|
-
app.use(express.raw({
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
app.use(express.raw({
|
|
20
|
+
type: 'application/actual-sync',
|
|
21
|
+
limit: `${config.get('upload.fileSizeSyncLimitMB')}mb`,
|
|
22
|
+
}));
|
|
23
|
+
app.use(express.raw({
|
|
24
|
+
type: 'application/encrypted-file',
|
|
25
|
+
limit: `${config.get('upload.syncEncryptedFileSizeLimitMB')}mb`,
|
|
26
|
+
}));
|
|
27
|
+
app.use(express.json({ limit: `${config.get('upload.fileSizeLimitMB')}mb` }));
|
|
21
28
|
export { app as handlers };
|
|
22
29
|
const OK_RESPONSE = { status: 'ok' };
|
|
23
30
|
function boolToInt(deleted) {
|
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) => {
|
package/build/src/load-config.js
CHANGED
|
@@ -233,6 +233,24 @@ const configSchema = convict({
|
|
|
233
233
|
default: 'manual',
|
|
234
234
|
env: 'ACTUAL_USER_CREATION_MODE',
|
|
235
235
|
},
|
|
236
|
+
github: {
|
|
237
|
+
doc: 'GitHub API configuration.',
|
|
238
|
+
token: {
|
|
239
|
+
doc: 'GitHub Personal Access Token for API authentication.',
|
|
240
|
+
format: String,
|
|
241
|
+
default: '',
|
|
242
|
+
env: 'ACTUAL_GITHUB_TOKEN',
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
corsProxy: {
|
|
246
|
+
doc: 'CORS proxy configuration for frontend plugins.',
|
|
247
|
+
enabled: {
|
|
248
|
+
doc: 'Enable the CORS proxy endpoint.',
|
|
249
|
+
format: Boolean,
|
|
250
|
+
default: false,
|
|
251
|
+
env: 'ACTUAL_CORS_PROXY_ENABLED',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
236
254
|
});
|
|
237
255
|
let configPath = null;
|
|
238
256
|
if (process.env.ACTUAL_CONFIG_PATH) {
|
|
@@ -261,6 +279,8 @@ debug(`User files: ${configSchema.get('userFiles')}`);
|
|
|
261
279
|
debug(`Web root: ${configSchema.get('webRoot')}`);
|
|
262
280
|
debug(`Login method: ${configSchema.get('loginMethod')}`);
|
|
263
281
|
debug(`Allowed methods: ${configSchema.get('allowedLoginMethods').join(', ')}`);
|
|
282
|
+
const corsProxyEnabled = configSchema.get('corsProxy.enabled');
|
|
283
|
+
debug(`CORS Proxy enabled: ${corsProxyEnabled}`);
|
|
264
284
|
const httpsKey = configSchema.get('https.key');
|
|
265
285
|
if (httpsKey) {
|
|
266
286
|
debug(`HTTPS Key: ${'*'.repeat(httpsKey.length)}`);
|
|
@@ -271,4 +291,9 @@ if (httpsCert) {
|
|
|
271
291
|
debug(`HTTPS Cert: ${'*'.repeat(httpsCert.length)}`);
|
|
272
292
|
debugSensitive(`HTTPS Cert: ${httpsCert}`);
|
|
273
293
|
}
|
|
294
|
+
const githubToken = configSchema.get('github.token');
|
|
295
|
+
if (githubToken) {
|
|
296
|
+
debug(`GitHub Token: ${'*'.repeat(Math.min(githubToken.length, 20))}`);
|
|
297
|
+
debugSensitive(`GitHub Token: ${githubToken}`);
|
|
298
|
+
}
|
|
274
299
|
export { configSchema as config };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actual-app/sync-server",
|
|
3
|
-
"version": "25.10.0-nightly.
|
|
3
|
+
"version": "25.10.0-nightly.20250925",
|
|
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.
|
|
31
|
+
"@actual-app/web": "25.10.0-nightly.20250925",
|
|
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",
|