mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
fix(stalker): close the abort window around the async prehash, and repair logins
Two review findings on the cancellation work: - `getProfile()` and `performHandshake()` await `generatePrehash()`, so an abort landing during that await left the caller's pre-check stale and the request went out anyway — precisely the avoidable case where an abandoned candidate adopts the MAC's token behind a healthy one. The check now sits immediately before each dispatch, inside the API methods. - Lazy repair called `discover()` without the playlist's stored credentials, so a login/password portal answered status 2 during confirmation and could never be repaired despite holding a working login. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2cf0ff8214
commit
bdb4e555d6
4 files changed
+83
-8
No files matched your search
@@ -318,6 +318,25 @@ describe('StalkerAuthApi', () => {
|
||||
expect(sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechecks the abort after the async prehash, not just before the call', async () => {
|
||||
// getProfile awaits generatePrehash(); an abort landing during that
|
||||
// await would otherwise still let the adopting request go out.
|
||||
const abandon = new AbortController();
|
||||
const digest = globalThis.crypto.subtle.digest as jest.Mock;
|
||||
digest.mockImplementation(async () => {
|
||||
abandon.abort();
|
||||
return new Uint8Array(20).fill(1).buffer;
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.getProfile(portalUrl, macAddress, 'TOKEN-1', {}, 'r1', {
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the status-2 login flow when abandoned mid-way', async () => {
|
||||
const abandon = new AbortController();
|
||||
sendIpcEvent
|
||||
|
||||
@@ -190,7 +190,8 @@ export class StalkerAuthApi {
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {},
|
||||
storedToken?: string
|
||||
storedToken?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StalkerHandshakeOutcome> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
const prehash = await generatePrehash(macAddress);
|
||||
@@ -206,6 +207,10 @@ export class StalkerAuthApi {
|
||||
};
|
||||
|
||||
try {
|
||||
// Re-checked here rather than only at the call site: the prehash
|
||||
// above is async, so an abort can land while it is being
|
||||
// computed and the request would still go out.
|
||||
assertNotAborted(signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
|
||||
STALKER_REQUEST,
|
||||
@@ -248,7 +253,11 @@ export class StalkerAuthApi {
|
||||
token: string,
|
||||
identity: StalkerPortalIdentity,
|
||||
handshakeRandom: string,
|
||||
options: { authSecondStep?: boolean; notValidToken?: boolean } = {}
|
||||
options: {
|
||||
authSecondStep?: boolean;
|
||||
notValidToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
} = {}
|
||||
): Promise<StalkerProfileResponse> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
|
||||
@@ -296,6 +305,10 @@ export class StalkerAuthApi {
|
||||
};
|
||||
|
||||
try {
|
||||
// The last possible moment before the call that adopts the MAC's
|
||||
// token portal-side — the prehash above is async, so the caller's
|
||||
// pre-check can be stale by now.
|
||||
assertNotAborted(options.signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
|
||||
STALKER_REQUEST,
|
||||
@@ -327,7 +340,8 @@ export class StalkerAuthApi {
|
||||
macAddress: string,
|
||||
token: string,
|
||||
credentials: StalkerPortalCredentials,
|
||||
identity: StalkerPortalIdentity = {}
|
||||
identity: StalkerPortalIdentity = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
const params: Record<string, string> = {
|
||||
@@ -345,6 +359,7 @@ export class StalkerAuthApi {
|
||||
};
|
||||
|
||||
try {
|
||||
assertNotAborted(signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
|
||||
STALKER_REQUEST,
|
||||
@@ -387,7 +402,8 @@ export class StalkerAuthApi {
|
||||
portalUrl,
|
||||
macAddress,
|
||||
normalizedIdentity,
|
||||
options.storedToken
|
||||
options.storedToken,
|
||||
options.signal
|
||||
);
|
||||
|
||||
// An unchanged stored token is already an adopted session — the
|
||||
@@ -414,7 +430,11 @@ export class StalkerAuthApi {
|
||||
handshake.token,
|
||||
normalizedIdentity,
|
||||
handshake.random,
|
||||
{ authSecondStep: false, notValidToken: handshake.notValid }
|
||||
{
|
||||
authSecondStep: false,
|
||||
notValidToken: handshake.notValid,
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
|
||||
// The envelope the login flow actually settled on: after a status-2
|
||||
@@ -472,7 +492,8 @@ export class StalkerAuthApi {
|
||||
macAddress,
|
||||
handshake.token,
|
||||
{ username, password },
|
||||
identity
|
||||
identity,
|
||||
options.signal
|
||||
);
|
||||
if (!accepted) {
|
||||
throw new StalkerPortalError('login-rejected');
|
||||
@@ -485,7 +506,11 @@ export class StalkerAuthApi {
|
||||
handshake.token,
|
||||
identity,
|
||||
handshake.random,
|
||||
{ authSecondStep: true, notValidToken: handshake.notValid }
|
||||
{
|
||||
authSecondStep: true,
|
||||
notValidToken: handshake.notValid,
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
settled = retried;
|
||||
js = retried?.js;
|
||||
|
||||
@@ -282,6 +282,26 @@ describe('StalkerPortalRepairService', () => {
|
||||
expect(writtenRow).toBeNull();
|
||||
});
|
||||
|
||||
it("forwards the playlist's stored credentials to discovery", async () => {
|
||||
// A login/password portal answers status 2 during confirmation;
|
||||
// without the credentials the probe reports `login-required` and
|
||||
// the source could never be repaired.
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
|
||||
await service.repairPortal({
|
||||
...MISCLASSIFIED,
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
expect(discover).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
{ credentials: { username: 'user', password: 'secret' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('probes at most once per playlist per session', async () => {
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
|
||||
|
||||
@@ -336,7 +336,18 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
const outcome = await this.discovery.discover(
|
||||
playlist.portalUrl ?? '',
|
||||
playlist.macAddress ?? '',
|
||||
getStalkerPortalIdentityFromPlaylist(playlist)
|
||||
getStalkerPortalIdentityFromPlaylist(playlist),
|
||||
{
|
||||
// A login/password portal answers `get_profile` with status 2
|
||||
// during confirmation. Without the stored credentials the
|
||||
// probe reports `login-required` and such a source could
|
||||
// never be repaired, even though the playlist holds a
|
||||
// working login.
|
||||
credentials: {
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (outcome.status !== 'resolved') {
|
||||
|
||||
Reference in new issue
Block a user