fix: 加固安全输入边界并修复订阅兼容性 (#3084)

* fix: harden scoped API inputs and isolate login throttling

* fix: close script path gaps and harden security boundaries

* fix: preserve subscription aliases and avoid extension eval

* fix: allow cancelling invalid historical subscriptions

* fix: make script parent validation explicit for CodeQL

* fix: limit command execution and clarify trusted path branches

* fix: separate diff tool script access from config APIs

* fix: clean up obsolete subscription SSH credentials
This commit is contained in:
whyour
2026-10-06 18:21:54 +08:00
committed by GitHub
parent 2ab9c702e6
commit 2cab112391
33 changed files with 2468 additions and 216 deletions
+102 -1
View File
@@ -48,7 +48,7 @@ function fixture(initial) {
},
'../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) },
'../shared/clientIp': {
getClientIp: () => '127.0.0.1',
getClientIp: (request) => request.socket.remoteAddress,
normalizeClientIp: (x) => x,
},
ip2region: class {
@@ -246,3 +246,104 @@ test('default credentials with historical metadata still require initialization'
);
assert.equal(f.auth.token, '');
});
test('one source cannot lock out another or revive a persisted global lock', async () => {
const f = fixture({ ...initialized(), retries: 40, lastlogon: Date.now() });
const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } };
const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } };
for (let i = 0; i < 3; i++) {
assert.equal(
(await f.user.login({ username: 'owner', password: 'wrong' }, attacker))
.code,
400,
);
}
assert.equal(
(
await f.user.login(
{ username: 'owner', password: 'old-password' },
attacker,
)
).code,
410,
);
assert.equal(
(await f.user.login({ username: 'owner', password: 'old-password' }, owner))
.code,
200,
);
// Another client's successful login does not reset the attacker's own budget.
assert.equal(
(await f.user.login({ username: 'owner', password: 'wrong' }, attacker))
.code,
410,
);
await f.user.resetAuthInfo({ retries: 0 });
assert.equal(
(
await f.user.login(
{ username: 'owner', password: 'old-password' },
attacker,
)
).code,
200,
);
});
test('TOTP guesses from another source do not lock the owner out', async () => {
const secret = authenticator.generateSecret();
const f = fixture({
...initialized(),
twoFactorActivated: true,
twoFactorSecret: secret,
});
const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } };
const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } };
await f.user.login({ username: 'owner', password: 'old-password' }, owner);
const good = authenticator.generate(secret);
const bad = good === '000000' ? '111111' : '000000';
for (let i = 0; i < 3; i++) {
assert.equal(
(
await f.user.twoFactorLogin(
{ username: 'owner', password: 'old-password', code: bad },
attacker,
)
).code,
430,
);
}
assert.equal(
(
await f.user.twoFactorLogin(
{ username: 'owner', password: 'old-password', code: bad },
attacker,
)
).code,
410,
);
assert.equal(
(
await f.user.twoFactorLogin(
{ username: 'owner', password: 'old-password', code: good },
owner,
)
).code,
200,
);
});
test('source backoff is capped and expires after inactivity', () => {
const { LoginThrottle } = load(path.resolve('back/shared/loginThrottle.ts'));
const throttle = new LoginThrottle();
let now = 1;
for (let i = 0; i < 20; i++) {
throttle.fail('attacker', now);
const wait = throttle.retryAfter('attacker', now);
assert.ok(wait <= 300);
now += (wait + 1) * 1000;
}
assert.equal(throttle.retryAfter('owner', now), 0);
assert.equal(throttle.retryAfter('attacker', now + 15 * 60 * 1000), 0);
assert.equal(throttle.fail('attacker', now + 15 * 60 * 1000), 1);
});