@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.
- package/build/src/app-cors-proxy.js +229 -0
- package/build/src/app-cors-proxy.test.js +448 -0
- package/build/src/app.js +4 -0
- package/build/src/load-config.js +45 -1
- package/build/src/load-config.test.js +92 -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.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
|
@@ -26,7 +26,26 @@ convict.addFormat({
|
|
|
26
26
|
return;
|
|
27
27
|
if (typeof val === 'number' && Number.isFinite(val) && val >= 0)
|
|
28
28
|
return;
|
|
29
|
-
|
|
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.
|
|
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.
|
|
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",
|