@b01lers/ctfd-api 1.0.0 → 1.2.0

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/README.md CHANGED
@@ -1,2 +1,57 @@
1
1
  # CTFd API
2
- A simple, typed API client for the CTFd API.
2
+ A simple, typed client for the CTFd API.
3
+
4
+ ### Installing
5
+ ```bash
6
+ npm i @b01lers/ctfd-api
7
+ ```
8
+
9
+ ### Motivation
10
+ Nominally, CTFd maintains documentation about their API endpoints through their
11
+ [API docs site](https://docs.ctfd.io/docs/api/redoc/). However, many response fields and request parameters remain
12
+ undocumented.
13
+
14
+ Furthermore, not every API endpoint is actually accessible through the `AccessToken`-based authorization
15
+ documented on the site, and certain endpoints have missing or incorrect data when accessed with an `AccessToken`;
16
+ notably,
17
+ - The [flag submission endpoint](https://docs.ctfd.io/docs/api/redoc/#tag/challenges/operation/post_challenge_attempt) always returns 403 when requested without an authenticated session cookie.
18
+ - The [challenge list endpoint](https://docs.ctfd.io/docs/api/redoc/#tag/challenges/operation/get_challenge_list) doesn't populate the `solved_by_me` field correctly when quested without an authenticated session cookie.
19
+
20
+ Therefore, the client provided by this library is a "user bot" that uses your credentials to authenticate the way a
21
+ regular user would.
22
+ ```ts
23
+ const client = new CTFdClient({
24
+ url: 'https://demo.ctfd.io/',
25
+ username: 'user',
26
+ password: 'password',
27
+ });
28
+ ```
29
+ Many of the types used and exported by this library are reverse engineered from CTFd's client requests as well, and may
30
+ likely be more comprehensive than what is documented on their official API docs above.
31
+
32
+ ### Usage
33
+ <!-- TODO: docs -->
34
+ Example usage that fetches challenges from the CTFd demo instance and attempts to submit a flag for a challenge:
35
+ ```ts
36
+ import { CTFdClient } from '@b01lers/ctfd-api';
37
+
38
+ const client = new CTFdClient({
39
+ url: 'https://demo.ctfd.io/',
40
+ username: 'user',
41
+ password: 'password',
42
+ });
43
+
44
+ // Fetch challenges list
45
+ const challs = await client.getChallenges();
46
+
47
+ // Fetch scoreboard data
48
+ const scoreboard = await client.getScoreboard();
49
+ console.log(scoreboard.slice(0, 5));
50
+
51
+ // Get details about a challenge, and submits a flag
52
+ const chall = challs.find((c) => c.name === 'The Lost Park')!;
53
+ const details = await client.getChallengeDetails(chall.id);
54
+ console.log(details.description);
55
+
56
+ await client.submitFlag(chall.id, 'cftd{test_flag}');
57
+ ```
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ class CTFdClient {
21
21
  }
22
22
  async submitFlag(id, flag) {
23
23
  const { session, nonce } = await this.getAuthedSessionNonce();
24
- return await (await fetch(`${this.url}/api/v1/challenges/attempt`, {
24
+ const res = await (await fetch(`${this.url}/api/v1/challenges/attempt`, {
25
25
  method: "POST",
26
26
  headers: {
27
27
  "Content-Type": "application/json",
@@ -30,12 +30,24 @@ class CTFdClient {
30
30
  },
31
31
  body: JSON.stringify({ challenge_id: id, submission: flag })
32
32
  })).json();
33
+ return res.data;
33
34
  }
34
35
  async getChallenges() {
35
36
  const { session } = await this.getAuthedSessionNonce();
36
- return await (await fetch(`${this.url}/api/v1/challenges`, {
37
+ const res = await (await fetch(`${this.url}/api/v1/challenges`, {
37
38
  headers: { cookie: session }
38
39
  })).json();
40
+ return res.data;
41
+ }
42
+ async getScoreboard() {
43
+ const { session, nonce } = await this.getAuthedSessionNonce();
44
+ const res = await (await fetch(`${this.url}/api/v1/scoreboard`, {
45
+ headers: {
46
+ "Csrf-Token": nonce,
47
+ cookie: session
48
+ }
49
+ })).json();
50
+ return res.data;
39
51
  }
40
52
  async getAuthedSessionNonce() {
41
53
  if (/* @__PURE__ */ new Date() < this.sessionExpiry && this.cachedSession && this.cachedNonce)
package/dist/types.d.ts CHANGED
@@ -1,18 +1,28 @@
1
- type ScoreboardData = {
1
+ type BaseScoreboardEntry = {
2
2
  pos: number;
3
3
  account_id: number;
4
4
  account_url: string;
5
- account_type: "user";
6
5
  oauth_id: null;
7
6
  name: string;
8
7
  score: number;
9
8
  bracket_id: null;
10
9
  bracket_name: null;
11
10
  };
12
- type ChallengesResponse = {
13
- success: true;
14
- data: ChallengeData[];
11
+ type ScoreboardUserEntry = BaseScoreboardEntry & {
12
+ account_type: "user";
13
+ };
14
+ type ScoreboardTeamEntry = BaseScoreboardEntry & {
15
+ account_type: "team";
16
+ members: {
17
+ id: number;
18
+ oauth_id: null;
19
+ name: string;
20
+ score: number;
21
+ bracket_id: null;
22
+ bracket_name: null;
23
+ }[];
15
24
  };
25
+ type ScoreboardEntry = ScoreboardUserEntry | ScoreboardTeamEntry;
16
26
  type ChallengeData = {
17
27
  id: number;
18
28
  type: 'standard' | 'multiple_choice' | 'code';
@@ -25,13 +35,6 @@ type ChallengeData = {
25
35
  template: string;
26
36
  value: number;
27
37
  };
28
- type FlagSubmissionResponse = {
29
- success: true;
30
- data: {
31
- status: "incorrect";
32
- message: "Incorrect";
33
- };
34
- };
35
38
 
36
39
  type ClientOptions = {
37
40
  url: string;
@@ -46,9 +49,16 @@ declare class CTFdClient {
46
49
  private cachedNonce;
47
50
  private sessionExpiry;
48
51
  constructor(options: ClientOptions);
49
- submitFlag(id: number, flag: string): Promise<FlagSubmissionResponse>;
50
- getChallenges(): Promise<ChallengesResponse>;
52
+ submitFlag(id: number, flag: string): Promise<{
53
+ status: "incorrect";
54
+ message: "Incorrect";
55
+ } | {
56
+ status: "correct";
57
+ message: "Correct";
58
+ }>;
59
+ getChallenges(): Promise<ChallengeData[]>;
60
+ getScoreboard(): Promise<ScoreboardEntry[]>;
51
61
  private getAuthedSessionNonce;
52
62
  }
53
63
 
54
- export { CTFdClient, type ChallengeData, type ScoreboardData };
64
+ export { CTFdClient, type ChallengeData, type ScoreboardEntry };
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@b01lers/ctfd-api",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "An API client for CTFd.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/types.d.ts",
7
+ "repository": "https://github.com/b01lers/ctfd-api.git",
7
8
  "scripts": {
8
9
  "build": "rollup --config"
9
10
  },
package/src/client.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { extractNonce } from './util';
2
- import type { ChallengesResponse, FlagSubmissionResponse } from './types';
2
+ import type {
3
+ ChallengeDetailsResponse,
4
+ ChallengesResponse,
5
+ FlagSubmissionResponse,
6
+ ScoreboardResponse
7
+ } from './types';
3
8
 
4
9
 
5
10
  type ClientOptions = {
@@ -26,7 +31,7 @@ export class CTFdClient {
26
31
  public async submitFlag(id: number, flag: string) {
27
32
  const { session, nonce } = await this.getAuthedSessionNonce();
28
33
 
29
- return await (await fetch(`${this.url}/api/v1/challenges/attempt`, {
34
+ const res = await (await fetch(`${this.url}/api/v1/challenges/attempt`, {
30
35
  method: 'POST',
31
36
  headers: {
32
37
  'Content-Type': 'application/json',
@@ -35,14 +40,41 @@ export class CTFdClient {
35
40
  },
36
41
  body: JSON.stringify({ challenge_id: id, submission: flag }),
37
42
  })).json() as FlagSubmissionResponse;
43
+
44
+ return res.data;
38
45
  }
39
46
 
40
47
  public async getChallenges() {
41
48
  const { session } = await this.getAuthedSessionNonce();
42
49
 
43
- return await (await fetch(`${this.url}/api/v1/challenges`, {
50
+ const res = await (await fetch(`${this.url}/api/v1/challenges`, {
44
51
  headers: { cookie: session }
45
52
  })).json() as ChallengesResponse;
53
+
54
+ return res.data;
55
+ }
56
+
57
+ public async getChallengeDetails(id: number) {
58
+ const { session } = await this.getAuthedSessionNonce();
59
+
60
+ const res = await (await fetch(`${this.url}/api/v1/challenges/${id}`, {
61
+ headers: { cookie: session }
62
+ })).json() as ChallengeDetailsResponse;
63
+
64
+ return res.data;
65
+ }
66
+
67
+ public async getScoreboard() {
68
+ const { session, nonce } = await this.getAuthedSessionNonce();
69
+
70
+ const res = await (await fetch(`${this.url}/api/v1/scoreboard`, {
71
+ headers: {
72
+ 'Csrf-Token': nonce,
73
+ cookie: session,
74
+ },
75
+ })).json() as ScoreboardResponse;
76
+
77
+ return res.data;
46
78
  }
47
79
 
48
80
  private async getAuthedSessionNonce() {
@@ -84,9 +116,3 @@ export class CTFdClient {
84
116
  };
85
117
  }
86
118
  }
87
-
88
- // export async function getScoreboard() {
89
- // return await (await fetch('https://ectf.ctfd.io/api/v1/scoreboard', {
90
- // headers: { 'Authorization': CTFD_API_KEY }
91
- // })).json() as ScoreboardResponse;
92
- // }
package/src/index.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { CTFdClient } from './client';
2
- export type { ScoreboardData, ChallengeData } from './types';
2
+ export type { ScoreboardEntry, ChallengeType, ChallengeData, ChallengeDetails } from './types';
package/src/types.ts CHANGED
@@ -1,13 +1,14 @@
1
- export type ScoreboardResponse = {
1
+ type APISuccess<T> = {
2
2
  success: true,
3
- data: ScoreboardData[],
3
+ data: T
4
4
  }
5
5
 
6
- export type ScoreboardData = {
6
+ export type ScoreboardResponse = APISuccess<ScoreboardEntry[]>
7
+
8
+ type BaseScoreboardEntry = {
7
9
  pos: number,
8
10
  account_id: number,
9
11
  account_url: string,
10
- account_type: "user",
11
12
  oauth_id: null,
12
13
  name: string,
13
14
  score: number,
@@ -15,14 +16,30 @@ export type ScoreboardData = {
15
16
  bracket_name: null
16
17
  }
17
18
 
18
- export type ChallengesResponse = {
19
- success: true,
20
- data: ChallengeData[]
19
+ type ScoreboardUserEntry = BaseScoreboardEntry & {
20
+ account_type: "user",
21
+ }
22
+
23
+ type ScoreboardTeamEntry = BaseScoreboardEntry & {
24
+ account_type: "team",
25
+ members: {
26
+ id: number,
27
+ oauth_id: null,
28
+ name: string,
29
+ score: number,
30
+ bracket_id: null,
31
+ bracket_name: null
32
+ }[]
21
33
  }
22
34
 
35
+ export type ScoreboardEntry = ScoreboardUserEntry | ScoreboardTeamEntry;
36
+
37
+ export type ChallengesResponse = APISuccess<ChallengeData[]>
38
+
39
+ export type ChallengeType = 'standard' | 'multiple_choice' | 'code';
23
40
  export type ChallengeData = {
24
41
  id: number,
25
- type: 'standard' | 'multiple_choice' | 'code',
42
+ type: ChallengeType,
26
43
  name: string,
27
44
  category: string,
28
45
  script: string,
@@ -33,10 +50,54 @@ export type ChallengeData = {
33
50
  value: number,
34
51
  }
35
52
 
36
- export type FlagSubmissionResponse = {
37
- success: true,
38
- data: {
39
- status: "incorrect", // TODO
40
- message: "Incorrect"
41
- }
53
+ export type ChallengeDetailsResponse = APISuccess<ChallengeDetails>;
54
+
55
+ type BaseChallengeDetails = {
56
+ id: number,
57
+ name: string,
58
+ value: number,
59
+ description: string,
60
+ category: string,
61
+ state: "visible", // TODO
62
+ max_attempts: number,
63
+ type_data: {
64
+ id: ChallengeType,
65
+ name: ChallengeType,
66
+ templates: { create: string, update: string, view: string },
67
+ scripts: { create: string, update: string, view: string }
68
+ },
69
+ solves: number,
70
+ solved_by_me: boolean,
71
+ attempts: number,
72
+ files: string[],
73
+ tags: string[],
74
+ hints: string[],
75
+ view: string
42
76
  }
77
+
78
+ type StandardChallengeDetails = BaseChallengeDetails & {
79
+ type: Exclude<ChallengeType, 'code'>
80
+ attribution: string | null,
81
+ connection_info: string | null,
82
+ next_id: number | null,
83
+ }
84
+
85
+ type ProgrammingChallengeDetails = BaseChallengeDetails & {
86
+ type: Extract<ChallengeType, 'code'>
87
+ language: string,
88
+ version: null,
89
+ output_enabled: null,
90
+ }
91
+
92
+ export type ChallengeDetails = StandardChallengeDetails | ProgrammingChallengeDetails;
93
+
94
+ export type FlagSubmissionResponse = APISuccess<{
95
+ status: 'incorrect',
96
+ message: 'Incorrect'
97
+ } | {
98
+ status: 'correct',
99
+ message: 'Correct'
100
+ } | {
101
+ status: 'already_solved',
102
+ message: 'You already solved this'
103
+ }>
package/tests/build.js CHANGED
@@ -8,9 +8,12 @@ const { CTFdClient } = require('../dist/index.js');
8
8
  });
9
9
 
10
10
  const challs = await client.getChallenges();
11
- console.log(challs.data);
11
+ console.log(challs);
12
12
 
13
- const chall = challs.data.find((c) => c.name === 'The Lost Park');
13
+ const scoreboard = await client.getScoreboard();
14
+ console.log(scoreboard.slice(0, 5));
15
+
16
+ const chall = challs.find((c) => c.name === 'The Lost Park');
14
17
  const res = await client.submitFlag(chall.id, 'cftd{test_flag}');
15
18
  console.log(res);
16
19
  })()
package/tests/demo.ts CHANGED
@@ -9,9 +9,15 @@ import { CTFdClient } from '../src/client';
9
9
  });
10
10
 
11
11
  const challs = await client.getChallenges();
12
- console.log(challs.data);
12
+ console.log(challs);
13
13
 
14
- const chall = challs.data.find((c) => c.name === 'The Lost Park')!;
15
- const res = await client.submitFlag(chall.id, 'cftd{test_flag}');
14
+ const scoreboard = await client.getScoreboard();
15
+ console.log(scoreboard.slice(0, 5));
16
+
17
+ const chall = challs.find((c) => c.name === 'The Lost Park')!;
18
+ const details = await client.getChallengeDetails(chall.id);
19
+ console.log(details);
20
+
21
+ const res = await client.submitFlag(chall.id, 'Major Mark Park');
16
22
  console.log(res);
17
23
  })()