@b01lers/ctfd-api 1.1.0 → 1.2.1
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 +28 -2
- package/dist/index.js +7 -0
- package/dist/types.d.ts +61 -4
- package/package.json +2 -1
- package/src/client.ts +16 -1
- package/src/index.ts +1 -1
- package/src/types.ts +65 -15
- package/tests/demo.ts +4 -1
- package/tests/rate-limit.ts +19 -0
package/README.md
CHANGED
|
@@ -6,6 +6,29 @@ A simple, typed client for the CTFd API.
|
|
|
6
6
|
npm i @b01lers/ctfd-api
|
|
7
7
|
```
|
|
8
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
|
+
|
|
9
32
|
### Usage
|
|
10
33
|
<!-- TODO: docs -->
|
|
11
34
|
Example usage that fetches challenges from the CTFd demo instance and attempts to submit a flag for a challenge:
|
|
@@ -25,7 +48,10 @@ const challs = await client.getChallenges();
|
|
|
25
48
|
const scoreboard = await client.getScoreboard();
|
|
26
49
|
console.log(scoreboard.slice(0, 5));
|
|
27
50
|
|
|
28
|
-
//
|
|
51
|
+
// Get details about a challenge, and submit a flag
|
|
29
52
|
const chall = challs.find((c) => c.name === 'The Lost Park')!;
|
|
30
|
-
await client.
|
|
53
|
+
const details = await client.getChallengeDetails(chall.id);
|
|
54
|
+
console.log(details.description);
|
|
55
|
+
|
|
56
|
+
await client.submitFlag(chall.id, 'ctfd{test_flag}');
|
|
31
57
|
```
|
package/dist/index.js
CHANGED
|
@@ -39,6 +39,13 @@ class CTFdClient {
|
|
|
39
39
|
})).json();
|
|
40
40
|
return res.data;
|
|
41
41
|
}
|
|
42
|
+
async getChallengeDetails(id) {
|
|
43
|
+
const { session } = await this.getAuthedSessionNonce();
|
|
44
|
+
const res = await (await fetch(`${this.url}/api/v1/challenges/${id}`, {
|
|
45
|
+
headers: { cookie: session }
|
|
46
|
+
})).json();
|
|
47
|
+
return res.data;
|
|
48
|
+
}
|
|
42
49
|
async getScoreboard() {
|
|
43
50
|
const { session, nonce } = await this.getAuthedSessionNonce();
|
|
44
51
|
const res = await (await fetch(`${this.url}/api/v1/scoreboard`, {
|
package/dist/types.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ type BaseScoreboardEntry = {
|
|
|
5
5
|
oauth_id: null;
|
|
6
6
|
name: string;
|
|
7
7
|
score: number;
|
|
8
|
-
bracket_id: null;
|
|
9
|
-
bracket_name: null;
|
|
8
|
+
bracket_id: number | null;
|
|
9
|
+
bracket_name: string | null;
|
|
10
10
|
};
|
|
11
11
|
type ScoreboardUserEntry = BaseScoreboardEntry & {
|
|
12
12
|
account_type: "user";
|
|
@@ -23,9 +23,10 @@ type ScoreboardTeamEntry = BaseScoreboardEntry & {
|
|
|
23
23
|
}[];
|
|
24
24
|
};
|
|
25
25
|
type ScoreboardEntry = ScoreboardUserEntry | ScoreboardTeamEntry;
|
|
26
|
+
type ChallengeType = 'standard' | 'multiple_choice' | 'code';
|
|
26
27
|
type ChallengeData = {
|
|
27
28
|
id: number;
|
|
28
|
-
type:
|
|
29
|
+
type: ChallengeType;
|
|
29
30
|
name: string;
|
|
30
31
|
category: string;
|
|
31
32
|
script: string;
|
|
@@ -35,6 +36,49 @@ type ChallengeData = {
|
|
|
35
36
|
template: string;
|
|
36
37
|
value: number;
|
|
37
38
|
};
|
|
39
|
+
type BaseChallengeDetails = {
|
|
40
|
+
id: number;
|
|
41
|
+
name: string;
|
|
42
|
+
value: number;
|
|
43
|
+
description: string;
|
|
44
|
+
category: string;
|
|
45
|
+
state: "visible";
|
|
46
|
+
max_attempts: number;
|
|
47
|
+
type_data: {
|
|
48
|
+
id: ChallengeType;
|
|
49
|
+
name: ChallengeType;
|
|
50
|
+
templates: {
|
|
51
|
+
create: string;
|
|
52
|
+
update: string;
|
|
53
|
+
view: string;
|
|
54
|
+
};
|
|
55
|
+
scripts: {
|
|
56
|
+
create: string;
|
|
57
|
+
update: string;
|
|
58
|
+
view: string;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
solves: number;
|
|
62
|
+
solved_by_me: boolean;
|
|
63
|
+
attempts: number;
|
|
64
|
+
files: string[];
|
|
65
|
+
tags: string[];
|
|
66
|
+
hints: string[];
|
|
67
|
+
view: string;
|
|
68
|
+
};
|
|
69
|
+
type StandardChallengeDetails = BaseChallengeDetails & {
|
|
70
|
+
type: Exclude<ChallengeType, 'code'>;
|
|
71
|
+
attribution: string | null;
|
|
72
|
+
connection_info: string | null;
|
|
73
|
+
next_id: number | null;
|
|
74
|
+
};
|
|
75
|
+
type ProgrammingChallengeDetails = BaseChallengeDetails & {
|
|
76
|
+
type: Extract<ChallengeType, 'code'>;
|
|
77
|
+
language: string;
|
|
78
|
+
version: null;
|
|
79
|
+
output_enabled: null;
|
|
80
|
+
};
|
|
81
|
+
type ChallengeDetails = StandardChallengeDetails | ProgrammingChallengeDetails;
|
|
38
82
|
|
|
39
83
|
type ClientOptions = {
|
|
40
84
|
url: string;
|
|
@@ -52,10 +96,23 @@ declare class CTFdClient {
|
|
|
52
96
|
submitFlag(id: number, flag: string): Promise<{
|
|
53
97
|
status: "incorrect";
|
|
54
98
|
message: "Incorrect";
|
|
99
|
+
} | {
|
|
100
|
+
status: "correct";
|
|
101
|
+
message: "Correct";
|
|
102
|
+
} | {
|
|
103
|
+
status: "already_solved";
|
|
104
|
+
message: "You already solved this";
|
|
105
|
+
} | {
|
|
106
|
+
status: "paused";
|
|
107
|
+
message: `${string} is paused`;
|
|
108
|
+
} | {
|
|
109
|
+
status: "ratelimited";
|
|
110
|
+
message: "You're submitting flags too fast. Slow down.";
|
|
55
111
|
}>;
|
|
56
112
|
getChallenges(): Promise<ChallengeData[]>;
|
|
113
|
+
getChallengeDetails(id: number): Promise<ChallengeDetails>;
|
|
57
114
|
getScoreboard(): Promise<ScoreboardEntry[]>;
|
|
58
115
|
private getAuthedSessionNonce;
|
|
59
116
|
}
|
|
60
117
|
|
|
61
|
-
export { CTFdClient, type ChallengeData, type ScoreboardEntry };
|
|
118
|
+
export { CTFdClient, type ChallengeData, type ChallengeDetails, type ChallengeType, type ScoreboardEntry };
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@b01lers/ctfd-api",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
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 {
|
|
2
|
+
import type {
|
|
3
|
+
ChallengeDetailsResponse,
|
|
4
|
+
ChallengesResponse,
|
|
5
|
+
FlagSubmissionResponse,
|
|
6
|
+
ScoreboardResponse
|
|
7
|
+
} from './types';
|
|
3
8
|
|
|
4
9
|
|
|
5
10
|
type ClientOptions = {
|
|
@@ -49,6 +54,16 @@ export class CTFdClient {
|
|
|
49
54
|
return res.data;
|
|
50
55
|
}
|
|
51
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
|
+
|
|
52
67
|
public async getScoreboard() {
|
|
53
68
|
const { session, nonce } = await this.getAuthedSessionNonce();
|
|
54
69
|
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { CTFdClient } from './client';
|
|
2
|
-
export type { ScoreboardEntry, ChallengeData } from './types';
|
|
2
|
+
export type { ScoreboardEntry, ChallengeType, ChallengeData, ChallengeDetails } from './types';
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
type APISuccess<T> = {
|
|
2
2
|
success: true,
|
|
3
|
-
data:
|
|
3
|
+
data: T
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
export type ScoreboardResponse = APISuccess<ScoreboardEntry[]>
|
|
7
|
+
|
|
6
8
|
type BaseScoreboardEntry = {
|
|
7
9
|
pos: number,
|
|
8
10
|
account_id: number,
|
|
@@ -10,8 +12,8 @@ type BaseScoreboardEntry = {
|
|
|
10
12
|
oauth_id: null,
|
|
11
13
|
name: string,
|
|
12
14
|
score: number,
|
|
13
|
-
bracket_id: null,
|
|
14
|
-
bracket_name: null
|
|
15
|
+
bracket_id: number | null,
|
|
16
|
+
bracket_name: string | null // e.g. "Open Bracket"
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
type ScoreboardUserEntry = BaseScoreboardEntry & {
|
|
@@ -32,14 +34,12 @@ type ScoreboardTeamEntry = BaseScoreboardEntry & {
|
|
|
32
34
|
|
|
33
35
|
export type ScoreboardEntry = ScoreboardUserEntry | ScoreboardTeamEntry;
|
|
34
36
|
|
|
35
|
-
export type ChallengesResponse =
|
|
36
|
-
success: true,
|
|
37
|
-
data: ChallengeData[]
|
|
38
|
-
}
|
|
37
|
+
export type ChallengesResponse = APISuccess<ChallengeData[]>
|
|
39
38
|
|
|
39
|
+
export type ChallengeType = 'standard' | 'multiple_choice' | 'code';
|
|
40
40
|
export type ChallengeData = {
|
|
41
41
|
id: number,
|
|
42
|
-
type:
|
|
42
|
+
type: ChallengeType,
|
|
43
43
|
name: string,
|
|
44
44
|
category: string,
|
|
45
45
|
script: string,
|
|
@@ -50,10 +50,60 @@ export type ChallengeData = {
|
|
|
50
50
|
value: number,
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export type
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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,
|
|
59
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
|
+
} | {
|
|
104
|
+
status: 'paused',
|
|
105
|
+
message: `${string} is paused`
|
|
106
|
+
} | {
|
|
107
|
+
status: 'ratelimited',
|
|
108
|
+
message: 'You\'re submitting flags too fast. Slow down.'
|
|
109
|
+
}>
|
package/tests/demo.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { CTFdClient } from '../src/client';
|
|
|
15
15
|
console.log(scoreboard.slice(0, 5));
|
|
16
16
|
|
|
17
17
|
const chall = challs.find((c) => c.name === 'The Lost Park')!;
|
|
18
|
-
const
|
|
18
|
+
const details = await client.getChallengeDetails(chall.id);
|
|
19
|
+
console.log(details);
|
|
20
|
+
|
|
21
|
+
const res = await client.submitFlag(chall.id, 'Major Mark Park');
|
|
19
22
|
console.log(res);
|
|
20
23
|
})()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CTFdClient } from '../src/client';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
;(async () => {
|
|
5
|
+
const client = new CTFdClient({
|
|
6
|
+
url: 'https://demo.ctfd.io/',
|
|
7
|
+
username: 'user',
|
|
8
|
+
password: 'password',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const chall = (await client.getChallenges()).find((c) => c.name === 'Too Many Puppers')!;
|
|
12
|
+
|
|
13
|
+
const res = await client.submitFlag(chall.id, 'test');
|
|
14
|
+
console.log(res);
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < 20; i++) {
|
|
17
|
+
console.log(await client.submitFlag(chall.id, 'test'));
|
|
18
|
+
}
|
|
19
|
+
})()
|