Skip to content

Commit 445f169

Browse files
authored
fix: escape */ to prevent premature doc comment termination (#102)
As a security measure, we want to escape the JSDoc closing char sequence (`*/`) to prevent unwanted code to be injected in the generated bindings.
1 parent 9aee1da commit 445f169

File tree

10 files changed

+228
-5
lines changed

10 files changed

+228
-5
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// Escapes doc comment content to prevent comment injection attacks.
2+
/// Replaces `*/` with `*\/` to prevent premature comment termination.
3+
pub(super) fn escape_doc_comment(line: &str) -> String {
4+
line.replace("*/", r"*\/")
5+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod comments;
12
pub mod javascript;
23
pub mod typescript;
34
pub mod typescript_native;

src/core/generate/rs/src/bindings/typescript.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Ported from https://github.com/dfinity/candid/blob/1ddf879f368f765145223c08bbe2c8c8f4782dcc/rust/candid_parser/src/bindings/typescript.rs
22
3+
use super::comments::escape_doc_comment;
34
use super::javascript::{ident, is_tuple_fields};
45
use candid::pretty::utils::*;
56
use candid::types::{Field, Function, Label, SharedLabel, Type, TypeEnv, TypeInner};
@@ -250,10 +251,9 @@ fn pp_docs<'a>(docs: &'a [String]) -> RcDoc<'a> {
250251
if docs.is_empty() {
251252
RcDoc::nil()
252253
} else {
253-
let docs = lines(
254-
docs.iter()
255-
.map(|line| RcDoc::text(DOC_COMMENT_LINE_PREFIX).append(line)),
256-
);
254+
let docs = lines(docs.iter().map(|line| {
255+
RcDoc::text(DOC_COMMENT_LINE_PREFIX).append(RcDoc::text(escape_doc_comment(line)))
256+
}));
257257
RcDoc::text(DOC_COMMENT_PREFIX)
258258
.append(RcDoc::hardline())
259259
.append(docs)

src/core/generate/rs/src/bindings/typescript_native/comments.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use super::conversion_functions_generator::TopLevelNodes;
2+
use crate::bindings::comments::escape_doc_comment;
23
use swc_core::common::{
34
BytePos, DUMMY_SP, Span,
45
comments::{Comment, CommentKind, Comments},
@@ -26,7 +27,7 @@ fn make_comment(docs: &[String]) -> Option<Comment> {
2627
// Join all doc lines into a single block comment, with each line prefixed by a space
2728
let mut comment_text = String::from("*\n");
2829
for line in docs {
29-
comment_text.push_str(&format!(" * {}\n", line));
30+
comment_text.push_str(&format!(" * {}\n", escape_doc_comment(line)));
3031
}
3132

3233
// Add a space at the end to align the block comment final line ("*/") properly

tests/assets/malicious_doc.did

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// */ import { malicious } from 'attacker'; console.log('injected!'); /*
2+
// Normal doc line
3+
// Another */ attempt /* to inject
4+
type MaliciousType = record {
5+
// Doc comment for field with */ malicious code /* in it
6+
field: text;
7+
};
8+
9+
// Service with */ malicious */ doc
10+
service : {
11+
// Method with */ in doc comment
12+
get : () -> (MaliciousType) query;
13+
}

tests/security.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { beforeAll, describe, expect, it } from 'vitest';
2+
import { wasmGenerate } from '../src/core/generate/rs.ts';
3+
import { testWasmInit } from './utils/wasm.ts';
4+
5+
const TESTS_ASSETS_DIR = './tests/assets';
6+
const SNAPSHOTS_BASE_DIR = './snapshots/wasm-generate';
7+
const SERVICE_NAME = 'malicious_doc';
8+
9+
beforeAll(async () => {
10+
await testWasmInit();
11+
});
12+
13+
describe('security', () => {
14+
describe('doc comment injection prevention', () => {
15+
it('should escape */ in doc comments to prevent code injection', async () => {
16+
const didFile = `${TESTS_ASSETS_DIR}/${SERVICE_NAME}.did`;
17+
18+
const result = wasmGenerate({
19+
did_file_path: didFile,
20+
service_name: SERVICE_NAME,
21+
declarations: {
22+
root_exports: true,
23+
},
24+
});
25+
26+
await expect(result.declarations_js).toMatchFileSnapshot(
27+
`${SNAPSHOTS_BASE_DIR}/${SERVICE_NAME}/declarations/${SERVICE_NAME}.did.js.snapshot`,
28+
);
29+
await expect(result.declarations_ts).toMatchFileSnapshot(
30+
`${SNAPSHOTS_BASE_DIR}/${SERVICE_NAME}/declarations/${SERVICE_NAME}.did.d.ts.snapshot`,
31+
);
32+
await expect(result.interface_ts).toMatchFileSnapshot(
33+
`${SNAPSHOTS_BASE_DIR}/${SERVICE_NAME}/${SERVICE_NAME}.d.ts.snapshot`,
34+
);
35+
await expect(result.service_ts).toMatchFileSnapshot(
36+
`${SNAPSHOTS_BASE_DIR}/${SERVICE_NAME}/${SERVICE_NAME}.ts.snapshot`,
37+
);
38+
});
39+
});
40+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { ActorMethod } from '@icp-sdk/core/agent';
2+
import type { IDL } from '@icp-sdk/core/candid';
3+
import type { Principal } from '@icp-sdk/core/principal';
4+
5+
/**
6+
* *\/ import { malicious } from 'attacker'; console.log('injected!'); /*
7+
* Normal doc line
8+
* Another *\/ attempt /* to inject
9+
*/
10+
export interface MaliciousType {
11+
/**
12+
* Doc comment for field with *\/ malicious code /* in it
13+
*/
14+
'field' : string,
15+
}
16+
/**
17+
* Service with *\/ malicious *\/ doc
18+
*/
19+
export interface _SERVICE {
20+
/**
21+
* Method with *\/ in doc comment
22+
*/
23+
'get' : ActorMethod<[], MaliciousType>,
24+
}
25+
export declare const idlService: IDL.ServiceClass;
26+
export declare const idlInitArgs: IDL.Type[];
27+
export declare const idlFactory: IDL.InterfaceFactory;
28+
export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[];
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { IDL } from '@icp-sdk/core/candid';
2+
3+
export const MaliciousType = IDL.Record({ 'field' : IDL.Text });
4+
5+
export const idlService = IDL.Service({
6+
'get' : IDL.Func([], [MaliciousType], ['query']),
7+
});
8+
9+
export const idlInitArgs = [];
10+
11+
export const idlFactory = ({ IDL }) => {
12+
const MaliciousType = IDL.Record({ 'field' : IDL.Text });
13+
14+
return IDL.Service({ 'get' : IDL.Func([], [MaliciousType], ['query']) });
15+
};
16+
17+
export const init = ({ IDL }) => { return []; };
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { type HttpAgentOptions, type ActorConfig, type Agent } from "@icp-sdk/core/agent";
2+
import type { Principal } from "@icp-sdk/core/principal";
3+
import { type _SERVICE } from "./declarations/malicious_doc.did";
4+
export interface Some<T> {
5+
__kind__: "Some";
6+
value: T;
7+
}
8+
export interface None {
9+
__kind__: "None";
10+
}
11+
export type Option<T> = Some<T> | None;
12+
export interface MaliciousType {
13+
/**
14+
* Doc comment for field with *\/ malicious code /* in it
15+
*/
16+
field: string;
17+
}
18+
/**
19+
* Service with *\/ malicious *\/ doc
20+
*/
21+
export interface malicious_docInterface {
22+
/**
23+
* Method with *\/ in doc comment
24+
*/
25+
get(): Promise<MaliciousType>;
26+
}
27+
export interface CreateActorOptions {
28+
agent?: Agent;
29+
agentOptions?: HttpAgentOptions;
30+
actorOptions?: ActorConfig;
31+
}
32+
export declare function createActor(canisterId: string, options: CreateActorOptions = {}): malicious_docInterface;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Actor, HttpAgent, type HttpAgentOptions, type ActorConfig, type Agent, type ActorSubclass } from "@icp-sdk/core/agent";
2+
import type { Principal } from "@icp-sdk/core/principal";
3+
import { idlFactory, type _SERVICE } from "./declarations/malicious_doc.did";
4+
export interface Some<T> {
5+
__kind__: "Some";
6+
value: T;
7+
}
8+
export interface None {
9+
__kind__: "None";
10+
}
11+
export type Option<T> = Some<T> | None;
12+
function some<T>(value: T): Some<T> {
13+
return {
14+
__kind__: "Some",
15+
value: value
16+
};
17+
}
18+
function none(): None {
19+
return {
20+
__kind__: "None"
21+
};
22+
}
23+
function isNone<T>(option: Option<T>): option is None {
24+
return option.__kind__ === "None";
25+
}
26+
function isSome<T>(option: Option<T>): option is Some<T> {
27+
return option.__kind__ === "Some";
28+
}
29+
function unwrap<T>(option: Option<T>): T {
30+
if (isNone(option)) {
31+
throw new Error("unwrap: none");
32+
}
33+
return option.value;
34+
}
35+
function candid_some<T>(value: T): [T] {
36+
return [
37+
value
38+
];
39+
}
40+
function candid_none<T>(): [] {
41+
return [];
42+
}
43+
function record_opt_to_undefined<T>(arg: T | null): T | undefined {
44+
return arg == null ? undefined : arg;
45+
}
46+
export interface MaliciousType {
47+
/**
48+
* Doc comment for field with *\/ malicious code /* in it
49+
*/
50+
field: string;
51+
}
52+
/**
53+
* Service with *\/ malicious *\/ doc
54+
*/
55+
export interface malicious_docInterface {
56+
/**
57+
* Method with *\/ in doc comment
58+
*/
59+
get(): Promise<MaliciousType>;
60+
}
61+
export class Malicious_doc implements malicious_docInterface {
62+
constructor(private actor: ActorSubclass<_SERVICE>){}
63+
async get(): Promise<MaliciousType> {
64+
const result = await this.actor.get();
65+
return result;
66+
}
67+
}
68+
export interface CreateActorOptions {
69+
agent?: Agent;
70+
agentOptions?: HttpAgentOptions;
71+
actorOptions?: ActorConfig;
72+
}
73+
export function createActor(canisterId: string, options: CreateActorOptions = {}): Malicious_doc {
74+
const agent = options.agent || HttpAgent.createSync({
75+
...options.agentOptions
76+
});
77+
if (options.agent && options.agentOptions) {
78+
console.warn("Detected both agent and agentOptions passed to createActor. Ignoring agentOptions and proceeding with the provided agent.");
79+
}
80+
const actor = Actor.createActor<_SERVICE>(idlFactory, {
81+
agent,
82+
canisterId: canisterId,
83+
...options.actorOptions
84+
});
85+
return new Malicious_doc(actor);
86+
}

0 commit comments

Comments
 (0)