Skip to content

Commit 182adfd

Browse files
committed
feat: 增加后端 CORS allowlist 配置并接入 App 模块参数
- Node 默认允许 `*`(兼容旧行为),可通过环境变量 `SUB_STORE_CORS_ALLOWED_ORIGINS` 配置 - 代理 App 模块默认 `https://sub-store.vercel.app`, 可通过模块参数 `cors` 配置
1 parent 0883152 commit 182adfd

12 files changed

Lines changed: 677 additions & 29 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ Example:
4646
sub.store = 127.0.0.1
4747
```
4848

49+
### CORS Allowlist
50+
51+
Sub-Store also supports a configurable browser CORS allowlist for the backend API. This does not change the module rewrite domain, but it limits which browser origins can read API responses through CORS.
52+
53+
- Node/server deployments use `SUB_STORE_CORS_ALLOWED_ORIGINS`; the default is `*` for compatibility.
54+
- Proxy App modules use the `cors` module argument; the default is `https://sub-store.vercel.app`.
55+
- Multiple origins can be separated by commas. Origins are matched exactly by scheme, host, and port. Set the value to `*` only when you accept the risk of any website reading the local backend through browser CORS.
56+
4957
## Core functionalities:
5058

5159
1. Conversion among various formats.

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.23.35",
3+
"version": "2.24.0",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"packageManager": "pnpm@11.0.9",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
import { expect } from 'chai';
5+
import { describe, it } from 'mocha';
6+
7+
import { load } from '@/utils/yaml';
8+
9+
const DEFAULT_ORIGIN = 'https://sub-store.vercel.app';
10+
11+
describe('module CORS allowlist config', function () {
12+
it('adds the default CORS argument to Surge modules', function () {
13+
for (const filename of ['Surge.sgmodule', 'Surge-Beta.sgmodule']) {
14+
const content = readConfig(filename);
15+
16+
expect(content).to.include(`cors:"${DEFAULT_ORIGIN}"`);
17+
expect(content).to.include('argument="cors={{{cors}}}"');
18+
expect(content.match(/argument="cors=\{\{\{cors\}\}\}"/g)).to.have
19+
.length(2);
20+
}
21+
});
22+
23+
it('adds the default CORS argument to the Egern module', function () {
24+
const config = load(readConfig('Egern.yaml'));
25+
const httpRequests = config.scriptings
26+
.map((item) => item.http_request)
27+
.filter(Boolean);
28+
29+
expect(config.compat_arguments.cors).to.equal(DEFAULT_ORIGIN);
30+
expect(config.compat_arguments_desc).to.include('1️⃣1️⃣ cors');
31+
expect(httpRequests).to.have.length(2);
32+
for (const script of httpRequests) {
33+
expect(script.arguments).to.deep.equal({
34+
'_compat.$argument': 'cors={{{cors}}}',
35+
});
36+
}
37+
});
38+
39+
it('adds the default CORS argument to the Loon module', function () {
40+
const content = readConfig('Loon.plugin');
41+
42+
expect(content).to.include(
43+
`cors=input, "${DEFAULT_ORIGIN}", tag=CORS允许来源`,
44+
);
45+
expect(content.match(/argument="cors=\{cors\}"/g)).to.have.length(2);
46+
});
47+
48+
it('does not set legacy non-parameterized modules to wildcard CORS', function () {
49+
for (const filename of [
50+
'QX.snippet',
51+
'Stash.stoverride',
52+
'Surge-Noability.sgmodule',
53+
'Surge-ability.sgmodule',
54+
]) {
55+
const content = readConfig(filename);
56+
57+
expect(content).to.not.match(/cors\s*[:=]\s*"?\*/i);
58+
}
59+
});
60+
});
61+
62+
function readConfig(filename) {
63+
return fs.readFileSync(path.join(findRepoRoot(), 'config', filename), 'utf8');
64+
}
65+
66+
function findRepoRoot() {
67+
for (const dir of [process.cwd(), path.resolve(process.cwd(), '..')]) {
68+
if (fs.existsSync(path.join(dir, 'config', 'Surge.sgmodule'))) {
69+
return dir;
70+
}
71+
}
72+
throw new Error('Unable to locate repository root');
73+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import {
5+
getCorsHeaders,
6+
isOriginAllowed,
7+
NON_NODE_CORS_DEFAULT,
8+
NODE_CORS_ALLOWED_ORIGINS_ENV,
9+
resolveCorsPolicy,
10+
} from '@/utils/cors';
11+
12+
describe('CORS allowlist policy', function () {
13+
it('defaults Node environments to wildcard origins', function () {
14+
const policy = resolveCorsPolicy({ isNode: true });
15+
16+
expect(policy).to.deep.include({
17+
wildcard: true,
18+
value: '*',
19+
source: 'default:node',
20+
});
21+
expect(isOriginAllowed(policy, 'https://evil.example')).to.equal(true);
22+
expect(getCorsHeaders(policy, 'https://evil.example')).to.deep.equal({
23+
'Access-Control-Allow-Origin': '*',
24+
});
25+
});
26+
27+
it('reads Node allowlist values from the environment setting', function () {
28+
const policy = resolveCorsPolicy({
29+
isNode: true,
30+
envValue:
31+
'https://sub-store.vercel.app, http://127.0.0.1:8888',
32+
});
33+
34+
expect(policy).to.deep.include({
35+
wildcard: false,
36+
source: `env:${NODE_CORS_ALLOWED_ORIGINS_ENV}`,
37+
value: 'https://sub-store.vercel.app,http://127.0.0.1:8888',
38+
});
39+
expect(
40+
isOriginAllowed(policy, 'https://sub-store.vercel.app'),
41+
).to.equal(true);
42+
expect(isOriginAllowed(policy, 'http://127.0.0.1:8888')).to.equal(
43+
true,
44+
);
45+
expect(isOriginAllowed(policy, 'https://evil.example')).to.equal(
46+
false,
47+
);
48+
});
49+
50+
it('defaults non-Node environments to the official frontend origin', function () {
51+
const policy = resolveCorsPolicy({ isNode: false });
52+
53+
expect(policy).to.deep.include({
54+
wildcard: false,
55+
source: 'default:non-node',
56+
value: NON_NODE_CORS_DEFAULT,
57+
});
58+
expect(isOriginAllowed(policy, NON_NODE_CORS_DEFAULT)).to.equal(true);
59+
expect(isOriginAllowed(policy, 'https://evil.example')).to.equal(
60+
false,
61+
);
62+
});
63+
64+
it('reads non-Node allowlist values from script arguments', function () {
65+
const policy = resolveCorsPolicy({
66+
isNode: false,
67+
argument:
68+
'sync_success_notify=true&cors=https%3A%2F%2Fsub-store.vercel.app%2Chttp%3A%2F%2F127.0.0.1%3A8888',
69+
});
70+
71+
expect(policy).to.deep.include({
72+
wildcard: false,
73+
source: 'argument:cors',
74+
value: 'https://sub-store.vercel.app,http://127.0.0.1:8888',
75+
});
76+
});
77+
78+
it('tolerates quoted script argument strings', function () {
79+
const policy = resolveCorsPolicy({
80+
isNode: false,
81+
argument:
82+
'"cors=https://sub-store.vercel.app,http://127.0.0.1:8888"',
83+
});
84+
85+
expect(policy).to.deep.include({
86+
wildcard: false,
87+
source: 'argument:cors',
88+
value: 'https://sub-store.vercel.app,http://127.0.0.1:8888',
89+
});
90+
});
91+
92+
it('supports explicit wildcard from non-Node arguments', function () {
93+
const policy = resolveCorsPolicy({
94+
isNode: false,
95+
argument: 'cors=*',
96+
});
97+
98+
expect(policy).to.deep.include({
99+
wildcard: true,
100+
source: 'argument:cors',
101+
value: '*',
102+
});
103+
expect(isOriginAllowed(policy, 'https://evil.example')).to.equal(true);
104+
});
105+
106+
it('requires exact browser origins', function () {
107+
const policy = resolveCorsPolicy({
108+
isNode: false,
109+
argument: {
110+
cors: 'https://sub-store.vercel.app,http://127.0.0.1:8888',
111+
},
112+
});
113+
114+
expect(isOriginAllowed(policy, 'https://sub-store.vercel.app')).to.equal(
115+
true,
116+
);
117+
expect(
118+
isOriginAllowed(policy, 'https://evil.example.sub-store.vercel.app'),
119+
).to.equal(false);
120+
expect(isOriginAllowed(policy, 'http://sub-store.vercel.app')).to.equal(
121+
false,
122+
);
123+
expect(isOriginAllowed(policy, 'http://127.0.0.1')).to.equal(false);
124+
});
125+
126+
it('does not treat empty configured values as wildcard', function () {
127+
const nodePolicy = resolveCorsPolicy({
128+
isNode: true,
129+
envValue: ' ',
130+
});
131+
const nonNodePolicy = resolveCorsPolicy({
132+
isNode: false,
133+
argument: 'cors=,,,',
134+
});
135+
136+
expect(nodePolicy).to.deep.include({
137+
wildcard: true,
138+
source: 'default:node',
139+
value: '*',
140+
});
141+
expect(nonNodePolicy).to.deep.include({
142+
wildcard: false,
143+
source: 'default:non-node',
144+
value: NON_NODE_CORS_DEFAULT,
145+
});
146+
expect(isOriginAllowed(nonNodePolicy, NON_NODE_CORS_DEFAULT)).to.equal(
147+
true,
148+
);
149+
});
150+
151+
it('returns concrete CORS headers for allowed non-wildcard origins', function () {
152+
const policy = resolveCorsPolicy({
153+
isNode: false,
154+
argument: {
155+
cors: 'https://sub-store.vercel.app',
156+
},
157+
});
158+
159+
expect(
160+
getCorsHeaders(policy, 'https://sub-store.vercel.app'),
161+
).to.deep.equal({
162+
'Access-Control-Allow-Origin': 'https://sub-store.vercel.app',
163+
Vary: 'Origin',
164+
});
165+
expect(getCorsHeaders(policy, 'https://evil.example')).to.deep.equal(
166+
{},
167+
);
168+
});
169+
});

0 commit comments

Comments
 (0)