Skip to content

Commit 64cd816

Browse files
brody-0125claude
andauthored
fix: harden security across API client, headers, CI/CD, and contact form (#59)
- scripts/api/client.js: mask API key in error logs, add response size limit (50MB), validate URL hostname against allowlist - scripts/api/seoul-metro-faci.js: cap pagination loop at MAX_PAGES=10 - All API modules: pass apiKey to fetchApi for error masking - web/public/_headers: add Strict-Transport-Security and Permissions-Policy - .github/workflows/deploy.yml: skip deploy on failed workflow_run - web/src/components/ContactModal.tsx: block form submission on reCAPTCHA failure with user-facing error message instead of silent fallback https://claude.ai/code/session_01LBvtFt23rsZctp48dkDb78 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ffc6052 commit 64cd816

12 files changed

Lines changed: 57 additions & 13 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ concurrency:
2727

2828
jobs:
2929
build:
30+
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
3031
runs-on: ubuntu-latest
3132
steps:
3233
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

scripts/api/air-quality.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export async function collect(apiKey) {
4848
const url = endpoint.url.replace("{KEY}", apiKey);
4949
const fields = endpoint.fields;
5050

51-
const raw = await fetchApi(url);
51+
const raw = await fetchApi(url, { apiKey });
5252

5353
if (raw.error) {
5454
return {

scripts/api/client.js

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
11
const MAX_RETRIES = 3;
22
const TIMEOUT_MS = 10000;
33
const BACKOFF_BASE_MS = 1000;
4+
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; // 50 MB
5+
const ALLOWED_HOSTS = ["openapi.seoul.go.kr"];
6+
7+
function maskApiKey(msg, apiKey) {
8+
if (!apiKey || !msg) return msg || "Unknown error";
9+
return msg.replaceAll(apiKey, "***");
10+
}
11+
12+
function validateUrl(url) {
13+
try {
14+
const parsed = new URL(url);
15+
if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
16+
return `Blocked request to disallowed host: ${parsed.hostname}`;
17+
}
18+
return null;
19+
} catch {
20+
return `Invalid URL`;
21+
}
22+
}
23+
24+
export async function fetchApi(url, { apiKey } = {}) {
25+
const hostError = validateUrl(url);
26+
if (hostError) {
27+
return { error: true, message: hostError, timestamp: new Date().toISOString() };
28+
}
429

5-
export async function fetchApi(url) {
630
let lastError;
731

832
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
@@ -22,18 +46,23 @@ export async function fetchApi(url) {
2246
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
2347
}
2448

49+
const contentLength = Number(response.headers.get("content-length") || 0);
50+
if (contentLength > MAX_RESPONSE_BYTES) {
51+
throw new Error(`Response too large: ${contentLength} bytes (limit ${MAX_RESPONSE_BYTES})`);
52+
}
53+
2554
const data = await response.json();
2655
return data;
2756
} catch (err) {
2857
clearTimeout(timeoutId);
2958
lastError = err;
30-
console.error(`Attempt ${attempt + 1}/${MAX_RETRIES} failed: ${err.message}`);
59+
console.error(`Attempt ${attempt + 1}/${MAX_RETRIES} failed: ${maskApiKey(err.message, apiKey)}`);
3160
}
3261
}
3362

3463
return {
3564
error: true,
36-
message: lastError?.message || "Unknown error",
65+
message: maskApiKey(lastError?.message, apiKey),
3766
timestamp: new Date().toISOString(),
3867
};
3968
}

scripts/api/disabled-restroom.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export async function collect(apiKey) {
88
const url = endpoint.url.replace("{KEY}", apiKey);
99
const fields = endpoint.fields;
1010

11-
const raw = await fetchApi(url);
11+
const raw = await fetchApi(url, { apiKey });
1212

1313
if (raw.error) {
1414
return {

scripts/api/helper.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export async function collect(apiKey) {
88
const url = endpoint.url.replace("{KEY}", apiKey);
99
const fields = endpoint.fields;
1010

11-
const raw = await fetchApi(url);
11+
const raw = await fetchApi(url, { apiKey });
1212

1313
if (raw.error) {
1414
return {

scripts/api/moving-walk.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export async function collect(apiKey) {
1616
const url = endpoint.url.replace("{KEY}", apiKey);
1717
const fields = endpoint.fields;
1818

19-
const raw = await fetchApi(url);
19+
const raw = await fetchApi(url, { apiKey });
2020

2121
if (raw.error) {
2222
return {

scripts/api/safety-board.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export async function collect(apiKey) {
1616
const url = endpoint.url.replace("{KEY}", apiKey);
1717
const fields = endpoint.fields;
1818

19-
const raw = await fetchApi(url);
19+
const raw = await fetchApi(url, { apiKey });
2020

2121
if (raw.error) {
2222
return {

scripts/api/seoul-metro-faci.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { fetchApi } from "./client.js";
22

33
const PAGE_SIZE = 1000;
4+
const MAX_PAGES = 10;
45
const SERVICE_NAME = "SeoulMetroFaciInfo";
56

67
let cachedRows = null;
@@ -16,10 +17,10 @@ export async function fetchAllFacilities(apiKey) {
1617
const allRows = [];
1718
let start = 1;
1819

19-
while (true) {
20+
for (let page = 0; page < MAX_PAGES; page++) {
2021
const end = start + PAGE_SIZE - 1;
2122
const url = `http://openapi.seoul.go.kr:8088/${apiKey}/json/${SERVICE_NAME}/${start}/${end}/`;
22-
const raw = await fetchApi(url);
23+
const raw = await fetchApi(url, { apiKey });
2324

2425
if (raw.error) {
2526
if (allRows.length > 0) break; // partial data is better than none

scripts/api/sign-language-phone.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export async function collect(apiKey) {
88
const url = endpoint.url.replace("{KEY}", apiKey);
99
const fields = endpoint.fields;
1010

11-
const raw = await fetchApi(url);
11+
const raw = await fetchApi(url, { apiKey });
1212

1313
if (raw.error) {
1414
return {

scripts/api/wheelchair-charger.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export async function collect(apiKey) {
88
const url = endpoint.url.replace("{KEY}", apiKey);
99
const fields = endpoint.fields;
1010

11-
const raw = await fetchApi(url);
11+
const raw = await fetchApi(url, { apiKey });
1212

1313
if (raw.error) {
1414
return {

0 commit comments

Comments
 (0)