Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ on:
- javascript-packages.txt
- launcher/**
- packages/code/src/**
- packages/extraction/**
- service/**
- shared/**
pull_request:
Expand Down Expand Up @@ -70,6 +71,8 @@ jobs:
run: |
images=$(jq -c . <<'JSON'
[
{"image": "code-interpreter-extraction", "dockerfile": "packages/extraction/Dockerfile", "target": "runtime",
"description": "Authenticated isolated PDF/DOCX extraction service", "timeout": 15},
{"image": "code-interpreter-api", "dockerfile": "service/Dockerfile", "target": "api",
"description": "Code API HTTP server (Compose service api)", "timeout": 30},
{"image": "code-interpreter-worker", "dockerfile": "service/Dockerfile", "target": "worker",
Expand Down
10 changes: 6 additions & 4 deletions packages/extraction/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ COPY packages/extraction/tsconfig.json ./
COPY packages/extraction/src ./src
RUN npm run build
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev linux-libc-dev && rm -rf /var/lib/apt/lists/*
COPY packages/extraction/native/guard.c ./guard.c
RUN gcc -O2 -Wall -Wextra -Werror -o guard guard.c
COPY packages/extraction/native/guard.c packages/extraction/native/gateway.c ./
RUN gcc -O2 -Wall -Wextra -Werror -o guard guard.c && gcc -O2 -Wall -Wextra -Werror -o gateway gateway.c
COPY packages/extraction/tests/block-landlock.c ./block-landlock.c
RUN gcc -O2 -Wall -Wextra -Werror -o block-landlock block-landlock.c

Expand All @@ -17,14 +17,16 @@ COPY packages/extraction/worker/requirements.txt /opt/extraction/requirements.tx
RUN pip install --no-cache-dir -r /opt/extraction/requirements.txt
COPY --from=build /usr/local/bin/node /usr/local/bin/node
COPY --from=build --chmod=0555 /build/guard /opt/extraction/guard
COPY --from=build --chmod=0555 /build/gateway /opt/extraction/gateway
COPY --from=build --chmod=0555 /build/dist /opt/extraction/dist
COPY --chmod=0444 packages/extraction/package.json /opt/extraction/package.json
COPY --chmod=0444 packages/extraction/worker/extract.py /opt/extraction/extract.py
RUN groupadd -g 10001 extraction && useradd -u 10001 -g extraction -M extraction && \
mkdir /socket /jobs && chown extraction:extraction /socket /jobs && chmod 0700 /jobs && chmod 0770 /socket
mkdir -p /run/extraction /jobs && chown extraction:extraction /run/extraction /jobs && chmod 0700 /jobs && chmod 0755 /run/extraction
USER 10001:10001
WORKDIR /opt/extraction
HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 CMD ["node", "dist/health.js"]
HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 CMD ["/opt/extraction/gateway", "node", "dist/health.js"]
ENTRYPOINT ["/opt/extraction/gateway"]
CMD ["node", "dist/main.js"]

FROM runtime AS qualification
Expand Down
31 changes: 28 additions & 3 deletions packages/extraction/compose.yaml
Original file line number Diff line number Diff line change
@@ -1,22 +1,47 @@
services:
extraction-init:
image: ${EXTRACTION_IMAGE:-librechat-extraction:local}
build:
context: ../..
dockerfile: packages/extraction/Dockerfile
target: runtime
command: [node, dist/provision.js]
user: '10001:10001'
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
network_mode: none
mem_limit: 128m
cpus: 1
pids_limit: 16
healthcheck:
disable: true
volumes:
- extraction-token:/run/extraction
extraction:
image: ${EXTRACTION_IMAGE:-librechat-extraction:local}
build:
context: ../..
dockerfile: packages/extraction/Dockerfile
target: runtime
depends_on:
extraction-init:
condition: service_completed_successfully
init: true
user: '10001:10001'
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
network_mode: none
networks: [extraction]
mem_limit: 768m
cpus: 2
pids_limit: 32
tmpfs:
- /jobs:rw,noexec,nosuid,nodev,size=96m,nr_inodes=1024,uid=10001,gid=10001,mode=0700
volumes:
- extraction-socket:/socket
- extraction-token:/run/extraction:ro
networks:
extraction:
internal: true
volumes:
extraction-socket:
extraction-token:
63 changes: 63 additions & 0 deletions packages/extraction/native/gateway.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>

#if defined(__x86_64__)
#define ARCH AUDIT_ARCH_X86_64
#elif defined(__aarch64__)
#define ARCH AUDIT_ARCH_AARCH64
#else
#error Unsupported architecture
#endif

#define DENY(nr) BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM)
int main(int argc, char **argv) {
if (argc < 2 || geteuid() == 0) goto fail;
/* No inherited socket, including a caller-supplied socket on standard IO. */
for (int fd = 0; fd < 3; fd++) {
struct stat stat;
if (fstat(fd, &stat)) goto fail;
if (S_ISSOCK(stat.st_mode)) {
int null = open("/dev/null", O_RDWR | O_CLOEXEC);
if (null < 0 || dup2(null, fd) < 0) goto fail;
close(null);
}
}
if (syscall(SYS_close_range, 3, ~0U, 0)) goto fail;
struct sock_filter filter[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ARCH, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
#ifdef __x86_64__
BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x40000000, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
#endif
DENY(__NR_socketpair),
DENY(__NR_sendmsg), DENY(__NR_recvmsg), DENY(__NR_sendmmsg), DENY(__NR_recvmmsg),
/* Only IP sockets. Docker's default filter remains in force underneath. */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_socket, 0, 5),
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AF_INET, 2, 0),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AF_INET6, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)
};
struct sock_fprog program = {.len = sizeof(filter) / sizeof(filter[0]), .filter = filter};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) goto fail;
execvp(argv[1], &argv[1]);
fail:
fputs("EXTRACTION_GATEWAY_UNAVAILABLE\n", stderr);
return 125;
}
8 changes: 8 additions & 0 deletions packages/extraction/native/guard.c
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ static void restrict_syscalls(void) {
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
#endif
DENY(__NR_socket), DENY(__NR_socketpair), DENY(__NR_connect),
DENY(__NR_sendmsg), DENY(__NR_recvmsg), DENY(__NR_sendmmsg), DENY(__NR_recvmmsg),
DENY(__NR_sendto), DENY(__NR_recvfrom), DENY(__NR_setsockopt), DENY(__NR_getsockopt),
DENY(__NR_shutdown), DENY(__NR_getsockname), DENY(__NR_getpeername),
DENY(__NR_bind), DENY(__NR_listen), DENY(__NR_accept), DENY(__NR_accept4),
DENY(__NR_clone), DENY(__NR_clone3),
#ifdef __NR_fork
Expand Down Expand Up @@ -119,6 +122,11 @@ static void restrict_syscalls(void) {
int main(int argc, char **argv) {
if (argc != 5 || geteuid() == 0) fail();
/* Paths are supervisor-owned, never supplied in the HTTP protocol. */
struct stat input, output, errors, null;
if (fstat(0, &input) || fstat(1, &output) || fstat(2, &errors) || stat("/dev/null", &null)) fail();
if (!S_ISCHR(input.st_mode) || input.st_rdev != null.st_rdev ||
!S_ISCHR(errors.st_mode) || errors.st_rdev != null.st_rdev ||
!S_ISREG(output.st_mode) || output.st_nlink != 1 || output.st_size != 0) fail();
if (chdir(argv[1])) fail();
pid_t parent = getppid();
if (parent == 1 || prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() != parent) fail();
Expand Down
134 changes: 75 additions & 59 deletions packages/extraction/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,67 +46,79 @@ export class RestrictedBackend implements Backend {
signal: AbortSignal
): Promise<string> {
signal.throwIfAborted();
const child = spawn(
this.runtime.guard,
[job, this.runtime.python, this.runtime.worker, mode],
{
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: {},
}
const status = await open(
join(job, 'status'),
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW |
constants.O_RDWR,
0o600
);
let output = '';
let count = 0;
let overflow = false;
const kill = () => {
if (
child.pid &&
child.exitCode === null &&
child.signalCode === null
) {
try {
process.kill(-child.pid, 'SIGKILL');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH')
this.healthy = false;
try {
signal.throwIfAborted();
const child = spawn(
this.runtime.guard,
[job, this.runtime.python, this.runtime.worker, mode],
{
detached: true,
stdio: ['ignore', status.fd, 'ignore'],
env: {},
}
}
};
signal.addEventListener('abort', kill, { once: true });
if (signal.aborted) kill();
let spawnError = false;
child.on('error', () => {
spawnError = true;
});
child.stdout.on('data', (chunk: Buffer) => {
count += chunk.length;
if (count > 1024) {
overflow = true;
kill();
} else output += chunk.toString('ascii');
});
child.stderr.on('data', (chunk: Buffer) => {
count += chunk.length;
if (count > 2048) {
overflow = true;
kill();
}
});
const code = await new Promise<number | null>(resolve =>
child.once('close', resolve)
);
signal.removeEventListener('abort', kill);
signal.throwIfAborted();
if (spawnError || overflow) throw new ExtractionError('UNAVAILABLE');
if (code !== 0) {
const value = output.trim();
if (parserErrors.has(value))
throw new ExtractionError(value as 'INVALID_DOCUMENT');
throw new ExtractionError(
code === null ? 'RESOURCE_LIMIT' : 'UNAVAILABLE'
);
const kill = () => {
if (
child.pid &&
child.exitCode === null &&
child.signalCode === null
) {
try {
process.kill(-child.pid, 'SIGKILL');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH')
this.healthy = false;
}
}
};
signal.addEventListener('abort', kill, { once: true });
if (signal.aborted) kill();
let spawnError = false;
child.on('error', () => {
spawnError = true;
});
const code = await new Promise<number | null>(resolve =>
child.once('close', resolve)
);
signal.removeEventListener('abort', kill);
signal.throwIfAborted();
const stat = await status.stat();
if (
spawnError ||
!stat.isFile() ||
stat.nlink !== 1 ||
stat.size > 1024
)
throw new ExtractionError('UNAVAILABLE');
const buffer = Buffer.alloc(1025);
const { bytesRead } = await status.read(
buffer,
0,
buffer.length,
0
);
if (bytesRead > 1024) throw new ExtractionError('UNAVAILABLE');
const output = buffer.toString('ascii', 0, bytesRead);
if (code !== 0) {
const value = output.trim();
if (parserErrors.has(value))
throw new ExtractionError(value as 'INVALID_DOCUMENT');
throw new ExtractionError(
code === null ? 'RESOURCE_LIMIT' : 'UNAVAILABLE'
);
}
return output;
} finally {
await status.close();
}
return output;
}
async probe(): Promise<void> {
const job = await this.create();
Expand All @@ -131,7 +143,7 @@ export class RestrictedBackend implements Backend {
await this.execute(job, format, signal);
const file = await open(
join(job, 'result'),
constants.O_RDONLY | constants.O_NOFOLLOW
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
);
try {
const stat = await file.stat();
Expand All @@ -142,7 +154,11 @@ export class RestrictedBackend implements Backend {
) {
throw new ExtractionError('OUTPUT_LIMIT');
}
const bytes = await file.readFile();
const buffer = Buffer.alloc(stat.size + 1);
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
if (bytesRead > policy.resultBytes)
throw new ExtractionError('OUTPUT_LIMIT');
const bytes = buffer.subarray(0, bytesRead);
signal.throwIfAborted();
validateResult(bytes, format, digest);
return bytes;
Expand Down
9 changes: 9 additions & 0 deletions packages/extraction/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { isIP } from 'node:net';

export function endpoint() {
const host = process.env.EXTRACTION_HOST ?? '0.0.0.0';
const port = process.env.EXTRACTION_PORT ?? '8000';
if (!isIP(host) || !/^[1-9]\d{0,4}$/.test(port) || Number(port) > 65535)
throw new Error('INVALID_ENDPOINT');
return { host, port: Number(port) };
}
Loading
Loading