icoa-cli 2.1.3 → 2.1.4

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.
@@ -7,11 +7,13 @@ import { getTranslation } from '../lib/translation.js';
7
7
  import { printSuccess, printError, printWarning, printInfo, printTable, printMarkdown, printHeader, printKeyValue, createSpinner, formatCountdown, } from '../lib/ui.js';
8
8
  function requireConnection() {
9
9
  const config = getConfig();
10
- if (!config.ctfdUrl || !config.token) {
11
- printError('Not connected to CTFd. Run: icoa ctf join <url>');
10
+ if (!config.ctfdUrl || (!config.token && !config.sessionCookie)) {
11
+ printError('Not connected to CTFd. Run: join <url>');
12
12
  process.exit(1);
13
13
  }
14
- return { config, client: new CTFdClient(config.ctfdUrl, config.token) };
14
+ const session = config.sessionCookie || '';
15
+ const token = config.token && !config.token.includes('session=') ? config.token : '';
16
+ return { config, client: new CTFdClient(config.ctfdUrl, token, session || config.token) };
15
17
  }
16
18
  export function registerCtfCommands(program) {
17
19
  const ctf = program.command('ctf').description('Competition commands');
@@ -30,7 +32,9 @@ export function registerCtfCommands(program) {
30
32
  { name: 'Username & Password', value: 'login' },
31
33
  ],
32
34
  });
33
- let token;
35
+ let token = '';
36
+ let sessionCookie = '';
37
+ let csrfNonce = '';
34
38
  if (authMethod === 'token') {
35
39
  token = await input({ message: 'Enter your CTFd Access Token:' });
36
40
  }
@@ -41,7 +45,10 @@ export function registerCtfCommands(program) {
41
45
  spinner.start();
42
46
  try {
43
47
  const client = new CTFdClient(url, '');
44
- token = await client.loginWithCredentials(username, password);
48
+ const result = await client.loginWithCredentials(username, password);
49
+ token = result.token;
50
+ sessionCookie = result.session;
51
+ csrfNonce = result.csrf;
45
52
  spinner.succeed('Login successful');
46
53
  }
47
54
  catch (err) {
@@ -54,7 +61,7 @@ export function registerCtfCommands(program) {
54
61
  const spinner = createSpinner('Testing connection...');
55
62
  spinner.start();
56
63
  try {
57
- const client = new CTFdClient(url, token);
64
+ const client = new CTFdClient(url, token, sessionCookie, csrfNonce);
58
65
  const user = await client.testConnection();
59
66
  spinner.succeed('Connected successfully');
60
67
  console.log();
@@ -68,10 +75,11 @@ export function registerCtfCommands(program) {
68
75
  const meta = await client.getCompetitionMeta();
69
76
  const configUpdate = {
70
77
  ctfdUrl: url,
71
- token: token,
78
+ token: token || sessionCookie,
72
79
  userId: user.id,
73
80
  userName: user.name,
74
81
  teamId: user.team_id,
82
+ sessionCookie: sessionCookie,
75
83
  };
76
84
  if (meta.start) {
77
85
  configUpdate.competitionStartsAt = new Date(meta.start * 1000).toISOString();
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ ${LINE}
36
36
  ${chalk.white('Sydney, Australia')} ${chalk.gray('Jun 27 - Jul 2, 2026')}
37
37
  ${chalk.cyan.underline('https://icoa2026.au')}
38
38
 
39
- ${chalk.gray('CLI-Native Competition Terminal v2.1.3')}
39
+ ${chalk.gray('CLI-Native Competition Terminal v2.1.4')}
40
40
 
41
41
  ${LINE}
42
42
  `;
@@ -2,7 +2,11 @@ import type { CTFdChallenge, CTFdChallengeListItem, CTFdUser, CTFdTeam, CTFdScor
2
2
  export declare class CTFdClient {
3
3
  private baseUrl;
4
4
  private token;
5
- constructor(baseUrl: string, token: string);
5
+ private sessionCookie;
6
+ private csrfNonce;
7
+ constructor(baseUrl: string, token: string, sessionCookie?: string, csrfNonce?: string);
8
+ private getAuthHeaders;
9
+ fetchCsrfNonce(): Promise<string>;
6
10
  private request;
7
11
  testConnection(): Promise<CTFdUser>;
8
12
  getChallenges(): Promise<CTFdChallengeListItem[]>;
@@ -18,5 +22,9 @@ export declare class CTFdClient {
18
22
  }>;
19
23
  getChallengeFiles(id: number): Promise<string[]>;
20
24
  downloadFile(filePath: string, destDir: string): Promise<string>;
21
- loginWithCredentials(username: string, password: string): Promise<string>;
25
+ loginWithCredentials(username: string, password: string): Promise<{
26
+ token: string;
27
+ session: string;
28
+ csrf: string;
29
+ }>;
22
30
  }
@@ -5,14 +5,52 @@ import { Readable } from 'node:stream';
5
5
  export class CTFdClient {
6
6
  baseUrl;
7
7
  token;
8
- constructor(baseUrl, token) {
8
+ sessionCookie;
9
+ csrfNonce;
10
+ constructor(baseUrl, token, sessionCookie, csrfNonce) {
9
11
  this.baseUrl = baseUrl.replace(/\/+$/, '');
10
12
  this.token = token;
13
+ this.sessionCookie = sessionCookie || '';
14
+ this.csrfNonce = csrfNonce || '';
15
+ }
16
+ getAuthHeaders() {
17
+ if (this.token) {
18
+ return { Authorization: `Token ${this.token}` };
19
+ }
20
+ if (this.sessionCookie) {
21
+ const headers = { Cookie: this.sessionCookie };
22
+ if (this.csrfNonce) {
23
+ headers['CSRF-Token'] = this.csrfNonce;
24
+ }
25
+ return headers;
26
+ }
27
+ return {};
28
+ }
29
+ async fetchCsrfNonce() {
30
+ if (this.csrfNonce)
31
+ return this.csrfNonce;
32
+ try {
33
+ const res = await fetch(this.baseUrl, {
34
+ headers: this.sessionCookie ? { Cookie: this.sessionCookie } : {},
35
+ });
36
+ const html = await res.text();
37
+ const match = html.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
38
+ if (match) {
39
+ this.csrfNonce = match[1];
40
+ return this.csrfNonce;
41
+ }
42
+ }
43
+ catch { /* ignore */ }
44
+ return '';
11
45
  }
12
46
  async request(method, path, body) {
47
+ // Auto-fetch CSRF nonce for session-based auth
48
+ if (this.sessionCookie && !this.csrfNonce) {
49
+ await this.fetchCsrfNonce();
50
+ }
13
51
  const url = `${this.baseUrl}/api/v1${path}`;
14
52
  const headers = {
15
- Authorization: `Token ${this.token}`,
53
+ ...this.getAuthHeaders(),
16
54
  'Content-Type': 'application/json',
17
55
  };
18
56
  const res = await fetch(url, {
@@ -43,7 +81,7 @@ export class CTFdClient {
43
81
  const res = await fetch(`${this.baseUrl}/api/v1/challenges/attempt`, {
44
82
  method: 'POST',
45
83
  headers: {
46
- Authorization: `Token ${this.token}`,
84
+ ...this.getAuthHeaders(),
47
85
  'Content-Type': 'application/json',
48
86
  },
49
87
  body: JSON.stringify({ challenge_id: challengeId, submission }),
@@ -85,13 +123,12 @@ export class CTFdClient {
85
123
  ? filePath
86
124
  : `${this.baseUrl}/${filePath.replace(/^\//, '')}`;
87
125
  const res = await fetch(url, {
88
- headers: { Authorization: `Token ${this.token}` },
126
+ headers: this.getAuthHeaders(),
89
127
  redirect: 'follow',
90
128
  });
91
129
  if (!res.ok || !res.body) {
92
130
  throw new Error(`Failed to download: ${url}`);
93
131
  }
94
- // Extract clean filename (strip query params like ?token=xxx)
95
132
  const rawName = filePath.split('/').pop() || 'file';
96
133
  const fileName = rawName.split('?')[0];
97
134
  const destPath = join(destDir, fileName);
@@ -103,7 +140,6 @@ export class CTFdClient {
103
140
  // Step 1: GET /login to get nonce
104
141
  const loginPageRes = await fetch(`${this.baseUrl}/login`);
105
142
  const loginHtml = await loginPageRes.text();
106
- // Try multiple nonce patterns (CTFd versions differ)
107
143
  const nonceMatch = loginHtml.match(/name="nonce"[^>]*value="([^"]+)"/) ||
108
144
  loginHtml.match(/value="([^"]+)"[^>]*name="nonce"/) ||
109
145
  loginHtml.match(/id="nonce"[^>]*value="([^"]+)"/) ||
@@ -112,7 +148,6 @@ export class CTFdClient {
112
148
  if (!nonce) {
113
149
  throw new Error('Could not extract CSRF nonce from login page.');
114
150
  }
115
- // Extract cookies
116
151
  const cookies = loginPageRes.headers.getSetCookie?.() || [];
117
152
  const cookieStr = cookies.map((c) => c.split(';')[0]).join('; ');
118
153
  // Step 2: POST /login with credentials
@@ -127,35 +162,46 @@ export class CTFdClient {
127
162
  });
128
163
  const loginCookies = loginRes.headers.getSetCookie?.() || [];
129
164
  const allCookies = [...cookies, ...loginCookies].map((c) => c.split(';')[0]).join('; ');
130
- // Check if login was successful (redirect to /challenges or /, not back to /login)
131
165
  const location = loginRes.headers.get('location') || '';
132
166
  if (location.includes('/login')) {
133
167
  throw new Error('Invalid username or password.');
134
168
  }
135
- // Step 3: Generate API token
136
- // First get CSRF nonce from settings page
137
- const settingsRes = await fetch(`${this.baseUrl}/settings`, {
138
- headers: { Cookie: allCookies },
139
- });
140
- const settingsHtml = await settingsRes.text();
141
- const csrfMatch = settingsHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
142
- const csrfNonce = csrfMatch?.[1] || nonce;
143
- const tokenRes = await fetch(`${this.baseUrl}/api/v1/tokens`, {
144
- method: 'POST',
145
- headers: {
146
- 'Content-Type': 'application/json',
147
- Cookie: allCookies,
148
- 'CSRF-Token': csrfNonce,
149
- },
150
- body: JSON.stringify({ expiration: '2026-12-31T23:59:59+00:00' }),
151
- });
152
- if (!tokenRes.ok) {
153
- throw new Error('Failed to generate API token. Please use a manual access token instead.');
169
+ // Step 3: Try to generate API token (some CTFd instances allow it)
170
+ try {
171
+ const settingsRes = await fetch(`${this.baseUrl}/settings`, {
172
+ headers: { Cookie: allCookies },
173
+ });
174
+ const settingsHtml = await settingsRes.text();
175
+ const csrfMatch = settingsHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
176
+ const csrfNonce = csrfMatch?.[1] || nonce;
177
+ const tokenRes = await fetch(`${this.baseUrl}/api/v1/tokens`, {
178
+ method: 'POST',
179
+ headers: {
180
+ 'Content-Type': 'application/json',
181
+ Cookie: allCookies,
182
+ 'CSRF-Token': csrfNonce,
183
+ },
184
+ body: JSON.stringify({ expiration: '2026-12-31T23:59:59+00:00' }),
185
+ });
186
+ if (tokenRes.ok) {
187
+ const tokenJson = (await tokenRes.json());
188
+ if (tokenJson.success && tokenJson.data?.value) {
189
+ return { token: tokenJson.data.value, session: '', csrf: '' };
190
+ }
191
+ }
154
192
  }
155
- const tokenJson = (await tokenRes.json());
156
- if (tokenJson.success && tokenJson.data?.value) {
157
- return tokenJson.data.value;
193
+ catch { /* Token generation not available, use session */ }
194
+ // Fallback: use session cookie + CSRF nonce
195
+ // Get CSRF nonce from the main page
196
+ let csrf = '';
197
+ try {
198
+ const mainRes = await fetch(`${this.baseUrl}/challenges`, { headers: { Cookie: allCookies } });
199
+ const mainHtml = await mainRes.text();
200
+ const csrfMatch = mainHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
201
+ if (csrfMatch)
202
+ csrf = csrfMatch[1];
158
203
  }
159
- throw new Error('Failed to generate API token. Response: ' + JSON.stringify(tokenJson));
204
+ catch { /* ignore */ }
205
+ return { token: '', session: allCookies, csrf };
160
206
  }
161
207
  }
package/dist/repl.js CHANGED
@@ -6,7 +6,6 @@ import { isActivated, activateToken, isFreeCommand, isDeviceMatch, recordExit, r
6
6
  import { resetTerminalTheme } from './lib/theme.js';
7
7
  import { ensureSandbox, runInSandbox, isDockerAvailable } from './lib/sandbox.js';
8
8
  import { logCommand } from './lib/logger.js';
9
- import { startLogSync, stopLogSync } from './lib/log-sync.js';
10
9
  import { existsSync, mkdirSync } from 'node:fs';
11
10
  import { join } from 'node:path';
12
11
  import { homedir } from 'node:os';
@@ -27,7 +26,7 @@ const BLOCKED_COMMANDS = new Set([
27
26
  'iptables', 'ufw', // firewall
28
27
  ]);
29
28
  const INTERCEPT = '__REPL_NO_EXIT__';
30
- const VERSION = '2.1.3';
29
+ const VERSION = '2.1.4';
31
30
  export async function startRepl(program, resumeMode) {
32
31
  const config = getConfig();
33
32
  const connected = isConnected();
@@ -138,8 +137,8 @@ export async function startRepl(program, resumeMode) {
138
137
  terminal: true,
139
138
  });
140
139
  let processing = false;
141
- // Start background log sync (every 30s to server)
142
- startLogSync();
140
+ // Log sync disabled until server audit endpoint is configured
141
+ // startLogSync();
143
142
  rl.prompt();
144
143
  rl.on('line', async (line) => {
145
144
  if (processing)
@@ -153,7 +152,7 @@ export async function startRepl(program, resumeMode) {
153
152
  logCommand(input);
154
153
  // Exit — record, reset terminal colors, and quit
155
154
  if (input === 'exit' || input === 'quit' || input === 'q') {
156
- stopLogSync();
155
+ // stopLogSync();
157
156
  recordExit();
158
157
  console.log(chalk.gray(' Session saved. Use ') + chalk.white('icoa --resume') + chalk.gray(' to continue.'));
159
158
  resetTerminalTheme();
@@ -296,7 +295,7 @@ export async function startRepl(program, resumeMode) {
296
295
  rl.prompt();
297
296
  });
298
297
  rl.on('close', () => {
299
- stopLogSync();
298
+ // stopLogSync();
300
299
  recordExit();
301
300
  resetTerminalTheme();
302
301
  realExit(0);
@@ -101,6 +101,7 @@ export interface IcoaConfig {
101
101
  accessToken: string;
102
102
  deviceFingerprint: string;
103
103
  lastVersion: string;
104
+ sessionCookie: string;
104
105
  }
105
106
  export type CompetitionState = 'pre_competition' | 'demo' | 'live' | 'finished' | 'unknown';
106
107
  export type HintLevel = 'A' | 'B' | 'C';
@@ -29,5 +29,6 @@ export const DEFAULT_CONFIG = {
29
29
  accessToken: '',
30
30
  deviceFingerprint: '',
31
31
  lastVersion: '',
32
+ sessionCookie: '',
32
33
  };
33
34
  export const SUPPORTED_LANGUAGES = ['en', 'zh', 'ja', 'ko', 'es'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {