Skip to content

Commit d422496

Browse files
committed
Defensive
1 parent 9735123 commit d422496

6 files changed

Lines changed: 74 additions & 15 deletions

File tree

apps/gif-service/src/emulator.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ export class Emulator {
242242
let trapCount = 0;
243243
while (status) {
244244
trapCount++;
245+
// Bound the trap loop: a normal tape load fires a few hundred traps,
246+
// so a runaway here is a malformed/hostile program, not real work.
247+
if (trapCount > 20000) {
248+
throw new Error('Too many tape traps in one frame');
249+
}
245250
switch (status) {
246251
case 1:
247252
throw new Error('Unrecognized opcode');

apps/gif-service/src/gif-generator.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,22 @@ export class GIFGenerator {
118118
const staleStop = this.options.staleFrameThreshold;
119119
const tailFrames = 25; // ~0.5s of static tail kept for readability
120120

121+
// Hard wall-clock backstop independent of frame count: emulation is
122+
// normally faster than real time, so generous headroom over the clip
123+
// length still catches a pathologically slow program.
124+
const renderDeadline = Date.now() + Math.max(this.options.maxDurationMs * 4, 60_000);
125+
121126
const frames: Uint8Array[] = [];
122127
const audio: AudioFrame[] = [];
123128
let previousFrame: Uint8Array | null = null;
124129
let staleCount = 0;
125130
let lastChangeIndex = -1;
126131

127132
for (let f = 0; f < maxFrames; f++) {
133+
if (Date.now() > renderDeadline) {
134+
console.warn(`Render wall-clock budget exceeded after ${f} frames; stopping`);
135+
break;
136+
}
128137
const frameBuffer = new Uint8Array(this.emulator.runFrame());
129138
frames.push(frameBuffer);
130139

apps/gif-service/src/hasura.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,44 @@ import { CompileError } from './errors.js';
55
// and call the compile / compileC actions. No admin secret is sent, so the
66
// service keeps holding no secrets.
77
const HASURA_URL = process.env.HASURA_URL ?? 'http://hasura:8080/v1/graphql';
8+
// Cap every Hasura call so a hung upstream compiler (zxbasic/z88dk) or a slow
9+
// lookup can't tie up a request indefinitely.
10+
const HASURA_TIMEOUT_MS = parseInt(process.env.HASURA_TIMEOUT_MS ?? '20000', 10);
811

912
interface GraphQLResponse<T> {
1013
data?: T;
1114
errors?: Array<{ message: string }>;
1215
}
1316

1417
async function gql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
15-
const res = await fetch(HASURA_URL, {
16-
method: 'POST',
17-
headers: { 'Content-Type': 'application/json' },
18-
body: JSON.stringify({ query, variables }),
19-
});
20-
if (!res.ok) {
21-
throw new Error(`Hasura returned ${res.status}`);
22-
}
23-
const body = (await res.json()) as GraphQLResponse<T>;
24-
if (body.errors?.length) {
25-
throw new Error(body.errors.map((e) => e.message).join('; '));
26-
}
27-
if (!body.data) {
28-
throw new Error('Hasura returned no data');
18+
const controller = new AbortController();
19+
const timer = setTimeout(() => controller.abort(), HASURA_TIMEOUT_MS);
20+
try {
21+
const res = await fetch(HASURA_URL, {
22+
method: 'POST',
23+
headers: { 'Content-Type': 'application/json' },
24+
body: JSON.stringify({ query, variables }),
25+
signal: controller.signal,
26+
});
27+
if (!res.ok) {
28+
throw new Error(`Hasura returned ${res.status}`);
29+
}
30+
const body = (await res.json()) as GraphQLResponse<T>;
31+
if (body.errors?.length) {
32+
throw new Error(body.errors.map((e) => e.message).join('; '));
33+
}
34+
if (!body.data) {
35+
throw new Error('Hasura returned no data');
36+
}
37+
return body.data;
38+
} catch (err) {
39+
if (controller.signal.aborted) {
40+
throw new Error(`Hasura request timed out after ${HASURA_TIMEOUT_MS}ms`);
41+
}
42+
throw err;
43+
} finally {
44+
clearTimeout(timer);
2945
}
30-
return body.data;
3146
}
3247

3348
export interface ProjectRecord {

apps/mastodon-bot/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const config = {
1111
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '15000', 10),
1212
maxSeconds: parseInt(process.env.MAX_SECONDS ?? '30', 10),
1313
maxMediaBytes: parseInt(process.env.MAX_MEDIA_BYTES ?? '8000000', 10),
14+
maxPerUserPerHour: parseInt(process.env.MAX_PER_USER_PER_HOUR ?? '10', 10),
1415
replyCaption: process.env.REPLY_CAPTION ?? '#ZXPlay',
1516
dryRun: process.env.DRY_RUN === 'true',
1617
};

apps/mastodon-bot/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { config, assertRuntimeConfig } from './config.js';
22
import { htmlToBasic } from './basic.js';
33
import { extractProjectRef } from './project.js';
44
import { parseDirectives } from './directives.js';
5+
import { allowRequest } from './ratelimit.js';
56
import { sourceToMedia, projectToMedia } from './media.js';
67
import { loadState, saveState } from './state.js';
78
import {
@@ -44,6 +45,13 @@ async function handleMention(self: MastodonAccount, n: MastodonNotification): Pr
4445
return;
4546
}
4647

48+
// Rate-limit per account to blunt a flood of mentions. Skip silently rather
49+
// than reply, so an attacker can't turn the limit into reply amplification.
50+
if (!allowRequest(status.account.acct, config.maxPerUserPerHour)) {
51+
console.log(`Mention ${n.id} from @${status.account.acct}: rate limit reached, skipping`);
52+
return;
53+
}
54+
4755
const machineType = projectRef ? (projectRef.machineType ?? directives.machineType) : directives.machineType;
4856
const lang = directives.lang ?? 'basic';
4957
const projectPath = projectRef ? `${projectRef.userSlug}/${projectRef.projectSlug}` : '';

apps/mastodon-bot/src/ratelimit.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// In-memory sliding-window rate limiter, keyed by account. State is per-process
2+
// (resets on restart), which is fine: the bot is a single instance and this only
3+
// needs to blunt a flood of mentions from one account, not enforce hard quotas.
4+
const hits = new Map<string, number[]>();
5+
const WINDOW_MS = 3_600_000; // one hour
6+
7+
/**
8+
* Record an attempt for `key` and report whether it is within `limit` over the
9+
* past hour. Returns false (and does not record) once the limit is reached.
10+
*/
11+
export function allowRequest(key: string, limit: number): boolean {
12+
const now = Date.now();
13+
const recent = (hits.get(key) ?? []).filter((t) => now - t < WINDOW_MS);
14+
if (recent.length >= limit) {
15+
hits.set(key, recent);
16+
return false;
17+
}
18+
recent.push(now);
19+
hits.set(key, recent);
20+
return true;
21+
}

0 commit comments

Comments
 (0)