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
29 changes: 29 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,32 @@ Common development tasks are defined in `deno.jsonc`:
- `deno task test` — run all tests
- `deno task bundle-component-library` — bundle the client-side component library
- `deno task setup` — first-time setup (downloads dependencies and vendor binaries)

## Running in the Agent Sandbox

The cloud agent sandbox intercepts outbound TLS with its own CA certificate. Deno does not trust this CA by default, so **all Deno commands must be prefixed with `DENO_TLS_CA_STORE=system`** to use the system certificate store. Without this, Deno cannot download JSR packages and will fail immediately with `invalid peer certificate: UnknownIssuer`.

```sh
DENO_TLS_CA_STORE=system deno task test
DENO_TLS_CA_STORE=system deno run --allow-all src/app/main.ts
```

### Starting the server for system tests

The system tests under `src/test/system/` connect to a live server at `http://localhost:8080`. The server must be running before executing `deno task test`. Start it in the background using the same environment variables that `src/scripts/dev-main.ts` sets:

```sh
FFS_ENV=dev \
FFS_STORE_ROOT=. \
FFS_USERS_FILE=data/users-file.json \
FFS_INSTANCE_SECRET=VerySecretIndeed \
FFS_CUSTOM_COMMANDS_FILE=data/sample-custom-commands.json \
DENO_TLS_CA_STORE=system \
nohup deno run --allow-all src/app/main.ts > /tmp/ffs-server.log 2>&1 &
```

Wait ~15 seconds for startup to complete (watch `/tmp/ffs-server.log` for `Starting server on port 8080`), then run:

```sh
DENO_TLS_CA_STORE=system deno test --allow-all
```
1 change: 1 addition & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 14 additions & 10 deletions src/lib/security/claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,23 @@ export class ClaimCodec {
}

async verifyAndUrlDecodeClaims(claimStr: string): Promise<Claims | null> {
const baseClaims = JSON.parse(decoder.decode(decodeBase64Url(claimStr)));
if (!baseClaims.claims || !baseClaims.hmac) {
return null;
}
try {
const baseClaims = JSON.parse(decoder.decode(decodeBase64Url(claimStr)));
if (!baseClaims.claims || !baseClaims.hmac) {
return null;
}

const stringifiedClaims = baseClaims.claims as string;
const hmac = decodeBase64(baseClaims.hmac);
const isValid = await this.#isValidClaim(stringifiedClaims, hmac);
if (!isValid) {
const stringifiedClaims = baseClaims.claims as string;
const hmac = decodeBase64(baseClaims.hmac);
const isValid = await this.#isValidClaim(stringifiedClaims, hmac);
if (!isValid) {
return null;
}

return JSON.parse(stringifiedClaims) as Claims;
} catch {
return null;
}

return JSON.parse(stringifiedClaims) as Claims;
}

async #signClaim(claim: string) {
Expand Down
28 changes: 24 additions & 4 deletions src/test/authenticated-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import { testApiKey } from './constants.ts';
import { baseUrl } from './constants.ts';
import './init-test-config.ts';

export function authenticatedFetch(
let cachedApiKey: string | null = null;

async function getApiKey(): Promise<string> {
if (cachedApiKey) {
return cachedApiKey;
}
const result = await fetch(baseUrl + '/api/logon', {
method: 'POST',
body: JSON.stringify({ username: 'admin', password: 'ffsadmin' }),
headers: { 'Content-Type': 'application/json' },
});
const response = await result.json();
if (!response.isOk || !response.key) {
throw new Error('Failed to log in during test setup');
}
cachedApiKey = response.key;
return cachedApiKey!;
}

export async function authenticatedFetch(
url: string,
opts: RequestInit | undefined = undefined,
): Promise<Response> {
const apiKey = await getApiKey();
if (opts === undefined) {
opts = {};
}

if (!opts.headers) {
opts.headers = {
'Authorization': `FFS ${testApiKey}`,
'Authorization': `FFS ${apiKey}`,
};
} else {
opts.headers = {
'Authorization': `FFS ${testApiKey}`,
'Authorization': `FFS ${apiKey}`,
...opts.headers,
};
}
Expand Down
1 change: 0 additions & 1 deletion src/test/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export const baseUrl = 'http://localhost:8080';
export const testApiKey = 'bab5b6be-2aad-11f0-a2ca-3b8bfdd0a613';
32 changes: 30 additions & 2 deletions src/test/system/api-security.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { ApiEndpointDefinition } from '../../app/api/sitemap/index.ts';
import { baseUrl } from '../constants.ts';
import { assertEquals } from '@std/assert/equals';
import { HTTP_401_UNAUTHORIZED } from '../../lib/http/http-codes.ts';
import { HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED } from '../../lib/http/http-codes.ts';

const publicApiEndpoints = ['/api/sitemap', '/api/logon', '/api/user-logon'];

const shareProtectedEndpoints = ['/api/share-file/list', '/api/share-file/download'];

Deno.test('All /api endpoints are secured, except those whitelisted', async () => {
const fetchResult = await fetch(baseUrl + '/api/sitemap');
const siteMap: ApiEndpointDefinition[] = await fetchResult.json();
const apiRoutes = siteMap.filter((x) =>
x.path.startsWith('/api/') && !publicApiEndpoints.includes(x.path)
x.path.startsWith('/api/') &&
!publicApiEndpoints.includes(x.path) &&
!shareProtectedEndpoints.includes(x.path)
);
for (const apiRoute of apiRoutes) {
for (const method of apiRoute.methods) {
Expand All @@ -25,3 +29,27 @@ Deno.test('All /api endpoints are secured, except those whitelisted', async () =
}
}
});

Deno.test('Share-protected endpoints return 400 when code param is missing', async () => {
for (const path of shareProtectedEndpoints) {
const result = await fetch(baseUrl + path);
assertEquals(
result.status,
HTTP_400_BAD_REQUEST,
`${path} should return 400 when code param is missing`,
);
await result.text();
}
});

Deno.test('Share-protected endpoints return 401 when code param is invalid', async () => {
for (const path of shareProtectedEndpoints) {
const result = await fetch(baseUrl + path + '?code=invalid-token');
assertEquals(
result.status,
HTTP_401_UNAUTHORIZED,
`${path} should return 401 when code is present but invalid`,
);
await result.text();
}
});
6 changes: 3 additions & 3 deletions src/test/system/file-listing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Deno.test('Can fetch directory contents', async () => {
const result = await authenticatedFetch(baseUrl + '/api/directory?path=.');
const directoryListing = await result.json();
assert(
directoryListing.some((x: FileListing) => x.name === 'deno.json' && x.isFile),
directoryListing.some((x: FileListing) => x.name === 'deno.jsonc' && x.isFile),
);
});

Expand All @@ -41,7 +41,7 @@ Deno.test('Listing directory gets unauthorized when not using API key', async ()
});

Deno.test('Fetching file works', async () => {
const result = await authenticatedFetch(baseUrl + '/api/file?path=deno.json');
const result = await authenticatedFetch(baseUrl + '/api/file?path=deno.jsonc');
const denoFile = await result.json();
assert(denoFile['imports'] && denoFile['imports']['@oak/oak']);
});
Expand All @@ -60,7 +60,7 @@ Deno.test('Not allowed to fetch / directory as file', async () => {

Deno.test('Can upload files', async () => {
const formData = new FormData();
const fileName = 'Makefile';
const fileName = 'README.md';
formData.append(
'file',
new Blob([Deno.readFileSync(fileName)], { type: 'text/plain' }),
Expand Down
11 changes: 8 additions & 3 deletions src/test/system/logon.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { assert } from '@std/assert/assert';
import { baseUrl } from '../constants.ts';
import { assertEquals } from '@std/assert/equals';

Deno.test('Using the right username and password gets us the API key', async () => {
const result = await fetch(baseUrl + '/api/logon', {
Expand All @@ -15,7 +14,10 @@ Deno.test('Using the right username and password gets us the API key', async ()
});
const response = await result.json();
assert(response['isOk']);
assertEquals(response['key'], 'bab5b6be-2aad-11f0-a2ca-3b8bfdd0a613');
assert(
typeof response['key'] === 'string' && response['key'].length > 0,
'Expected a non-empty API key string',
);
});

Deno.test('Using the right username and password gets us the API key - pbkdf2', async () => {
Expand All @@ -31,5 +33,8 @@ Deno.test('Using the right username and password gets us the API key - pbkdf2',
});
const response = await result.json();
assert(response['isOk']);
assertEquals(response['key'], '780423cd-0bde-489a-8a2c-369e12c22f19');
assert(
typeof response['key'] === 'string' && response['key'].length > 0,
'Expected a non-empty API key string',
);
});
6 changes: 4 additions & 2 deletions src/test/system/website.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ Deno.test('GET /logout redirects to the landing page', async () => {
const result = await fetch(baseUrl + '/logout', { redirect: 'manual' });
await result.body?.cancel();
assert(result.status >= 300 && result.status < 400);
assertEquals('/', new URL(result.headers.get('location')!).pathname);
const location = result.headers.get('location')!;
assertEquals(new URL(location, baseUrl).pathname, '/');
});

Deno.test('GET /logout clears the FFS-Authorization cookie', async () => {
const result = await fetch(baseUrl + '/logout', { redirect: 'manual' });
await result.body?.cancel();
const setCookie = result.headers.get('set-cookie') ?? '';
assert(setCookie.includes('FFS-Authorization='));
assert(setCookie.includes('Expires=') || setCookie.includes('Max-Age=0'));
const lowerSetCookie = setCookie.toLowerCase();
assert(lowerSetCookie.includes('expires=') || lowerSetCookie.includes('max-age=0'));
});