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
1 change: 0 additions & 1 deletion .github/workflows/code-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,4 @@ jobs:
- name: NPM / Verify
working-directory: code
run: |
npm ci --force
npm run verify
2 changes: 1 addition & 1 deletion cleanup-storage/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ FROM base AS deps

WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci --ignore-scripts

# Build step
FROM base AS builder
Expand Down
1,463 changes: 923 additions & 540 deletions cleanup-storage/package-lock.json

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions cleanup-storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,49 @@
"tsx": "^4.19.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.25.0"
},
"overrides": {
"fast-xml-parser": "^5.7.0",
"js-yaml": "^4.3.1",
"lodash": "^4.18.0",
"@humanfs/node": "^0.16.8",
"uuid": "^11.1.1",
"flatted": "^3.4.2",
"minimatch": "^9.0.7",
"brace-expansion": "^2.1.2",
"picomatch": "^4.0.4",
"eslint": {
"minimatch": {
".": "^3.1.3",
"brace-expansion": "^1.1.16"
}
},
"@eslint/eslintrc": {
"minimatch": {
".": "^3.1.3",
"brace-expansion": "^1.1.16"
}
},
"@eslint/config-array": {
"minimatch": {
".": "^3.1.3",
"brace-expansion": "^1.1.16"
}
},
"@humanwhocodes/config-array": {
"minimatch": {
".": "^3.1.3",
"brace-expansion": "^1.1.16"
}
},
"glob": {
"minimatch": {
".": "^3.1.3",
"brace-expansion": "^1.1.16"
}
},
"micromatch": {
"picomatch": "^2.3.2"
}
}
}
5 changes: 4 additions & 1 deletion code/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ WORKDIR /app
COPY /patches ./patches
COPY package*.json ./
RUN npm pkg delete scripts.prepare
RUN npm ci --force
RUN npm ci --force --ignore-scripts
RUN npm rebuild canvas sharp
RUN npm run copy:assets
RUN npm run apply:patches

# Build step
FROM base AS builder
Expand Down
2 changes: 1 addition & 1 deletion code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"publish:snapshot": "nx run-many -t publish:snapshot -- --verbose --tag next --registry $NPM_PUBLISHING_REGISTRY --unsafe-perm",
"start": "node --experimental-specifier-resolution=node --env-file=.env --loader ts-node/esm dist/server.js",
"storage:cleanup": "tsx --env-file=./src/actions/storage-cleanup/.env ./src/actions/storage-cleanup/action.ts",
"verify": "npm ci --force && npm run lint && npm run build",
"verify": "npm ci --force --ignore-scripts && npm rebuild canvas sharp && npm run copy:assets && npm run apply:patches && npm run lint && npm run build",
"version:development": "export BUMP_DEVELOP_VERSION=$(npm version $(npm version minor)-SNAPSHOT) && lerna version $BUMP_DEVELOP_VERSION --no-git-tag-version --no-push --force-publish --exact --yes && npm version $(npm version minor)-SNAPSHOT",
"version:release": "npm ci && lerna version --no-git-tag-version --no-push --force-publish --exact --yes $RELEASE_VERSION && npm version $RELEASE_VERSION"
},
Expand Down
2 changes: 1 addition & 1 deletion code/src/api/v1/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { postRemoveBackgroundController } from "./controllers/postRemoveBackgrou
import { getAzureWebPubsubServer } from "../../store.js";
import { getCorsMiddleware } from "../../middlewares/cors.js";
import { postValidateAIPassword } from "./controllers/postValidateIAPassword.js";
import { aiPasswordRateLimit } from "../../middlewares/ai-password-rate-limit.js";
import { getThreadsController } from "./controllers/getThreads.js";
import { getThreadController } from "./controllers/getThread.js";
import { postThreadController } from "./controllers/postThread.js";
Expand Down Expand Up @@ -112,6 +111,7 @@ import { postUploadRoomImageFallbackController } from "./controllers/postUploadR
import { getLogger } from "@/logger/logger.js";
import {
aiImageOperationRateLimit,
aiPasswordRateLimit,
roomImageUploadRateLimit,
roomVideoUploadRateLimit,
} from "@/middlewares/api-rate-limit.js";
Expand Down
45 changes: 37 additions & 8 deletions code/src/lib/aiPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,44 @@
//
// SPDX-License-Identifier: Apache-2.0

import { createHash, timingSafeEqual } from "crypto";
import { timingSafeEqual } from "crypto";

// Upper bound on the byte length considered for comparison. Generous for any
// realistic shared-secret password while keeping the encoded buffers small
// and fixed-size.
const MAX_COMPARISON_BYTES = 512;

/**
* Encodes a string as a fixed-size buffer: a 4-byte big-endian length prefix
* followed by its UTF-8 bytes, zero-padded (or truncated) to
* MAX_COMPARISON_BYTES.
*
* This lets `timingSafeEqual` compare the values directly - no hashing
* involved - while the length prefix ensures two different-length inputs
* can never be considered equal just because their zero-padded tails
* happen to match.
*/
function toFixedSizeBuffer(input: string): Buffer {
const raw = Buffer.from(input, "utf8");
const length = Math.min(raw.length, MAX_COMPARISON_BYTES);

const encoded = Buffer.alloc(4 + MAX_COMPARISON_BYTES);
encoded.writeUInt32BE(length, 0);
raw.copy(encoded, 4, 0, length);

return encoded;
}

/**
* Compares a provided AI password against the expected value using a
* constant-time algorithm to prevent timing attacks.
*
* Both values are hashed with SHA-256 before comparison so that
* `timingSafeEqual` always operates on fixed-length buffers regardless of
* the input lengths.
* Both values are encoded to fixed-length buffers before comparison (see
* `toFixedSizeBuffer`) so that `timingSafeEqual` always operates on
* equal-length buffers regardless of the input lengths. No hashing is
* involved: this compares a shared secret against itself, it does not hash
* and store a password, so a cryptographic hash function is neither
* required nor appropriate here.
*/
export function verifyAIPassword(
provided: string | string[] | undefined,
Expand All @@ -20,8 +49,8 @@ export function verifyAIPassword(
return false;
}

const providedHash = createHash("sha256").update(provided).digest();
const expectedHash = createHash("sha256").update(expected).digest();

return timingSafeEqual(providedHash, expectedHash);
return timingSafeEqual(
toFixedSizeBuffer(provided),
toFixedSizeBuffer(expected)
);
}
70 changes: 0 additions & 70 deletions code/src/middlewares/ai-password-rate-limit.ts

This file was deleted.

8 changes: 8 additions & 0 deletions code/src/middlewares/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,11 @@ export const roomVideoUploadRateLimit = createApiRateLimit({
max: 10,
message: "Too many video upload requests. Please try again later.",
});

// Guards against online brute-force guessing of the shared AI password:
// at most 10 attempts per 15-minute window.
export const aiPasswordRateLimit = createApiRateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: "Too many attempts. Please try again later.",
});
Loading